Skip to content

fix(mcp): recover failed pooled connections without replaying calls - #11392

Closed
destire-mio wants to merge 12 commits into
QwenLM:mainfrom
destire-mio:codex/fix-pooled-mcp-recovery
Closed

fix(mcp): recover failed pooled connections without replaying calls#11392
destire-mio wants to merge 12 commits into
QwenLM:mainfrom
destire-mio:codex/fix-pooled-mcp-recovery

Conversation

@destire-mio

@destire-mio destire-mio commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Restores previously acquired MCP connections and session tool, prompt and resource registrations before later model sends or resource reads in daemon/ACP sessions and their agents. Recovery uses the existing workspace pool, current permissions and normal authentication. It performs no tool invocation or replay.

Sessions retain their subscriptions while a shared connection is restarting. Discovery, recovery and runtime additions coordinate within the session; the pool coordinates process creation and old-connection cleanup across sessions. Removal invalidates an older queued addition before it writes configuration, and queued discovery rechecks folder trust when it starts. Failed runtime replacements roll back their own tentative in-memory configuration without restoring connection authority revoked by disconnect, removal or shutdown.

Pooled ACP sessions synchronize model tool declarations and prompt commands before each model send, including recovery that produces no diagnostic. Inherited agent tools retain their source, while independent overrides retain their own connection and resource ownership. Error handling reads the owning connection: a missing transport is rejected before sending, a returned JSON-RPC business error keeps its meaning, and a lost response retains an unknown-outcome/no-replay warning.

Why it's needed

A tool invocation, its process/transport and a session's pool reference have separate lifetimes. Cancelling one invocation can leave the shared process healthy. When the transport does fail, removing the pool entry previously left active sessions without a replacement path. Recovery must restore connection ownership and declarations without treating an interrupted operation as safe to repeat.

The counted local fixture establishes these recovery gaps, not the original reporter's cancellation mechanism. Ordinary cancellation left the fixture process alive; exit and protocol corruption were controlled faults. The runtime configuration rollback concerns in-memory state, not disk settings. A transport object can remain assigned after a network error, and an active pool entry can temporarily have a disconnected client during restart; neither state alone determines an invocation's outcome.

Reviewer Test Plan

How to verify

  • With a counted stdio service behind a loopback daemon and two ACP sessions, cancel A's slow call while B has another call in flight. B must complete, A's cancelled operation must execute once, and later calls must work. Terminate the process or corrupt the transport, then send concurrent follow-up requests; require one shared replacement process.
  • Disable or disconnect a service and require it to stay disconnected. Make replacement startup fail and check visible failure/cooldown, then restore startup and verify later recovery. Reject tool permission after reconnection and require zero denied calls on the wire. Discovery timeout must produce no tool execution.
  • Hold a shared restart in its initialize handshake while another session recovers. Keep the active subscription and avoid a false recovery cooldown. Interrupt runtime replacement with disconnect or remove; check the retained configuration and later explicit discovery, not only empty connection maps. Remove an addition before it reaches the front of the queue; the removed service must never start. Revoke folder trust before queued discovery begins; no new process may start.
  • Apply a counted HTTP tool side effect and drop the response socket while the SDK transport remains assigned. Both standalone and pooled paths must report the uncertain outcome without replay; a later new call must work. A tool whose transport is already absent must report not sent with wire count zero. A JSON-RPC application error with code 404 or disconnected-service wording must retain its original meaning.
  • Check that a registry removal with no recovery notice reaches the next ACP model request's tool declarations. Check inherited agent tools/resources and same-named independent overrides remain with their owners. During old-process cleanup, a timed-out acquire must retain the cleanup barrier; a draining process must retain its budget reservation until retirement.

Evidence (Before & After)

The repair is committed as fb125a154b51b227371bdcb49c916bcb1602f9d0, based on reviewed head 82b635b0e58044a8345ccdf62447469e765559be, including the maintainer's main merge. The tested CLI entrypoint SHA256 is 1f6de4cb4679d4c47add276e433feeea657616d9d33c0d351bff720419e02ef9. Its five-file production delta from the reviewed head has SHA256 7cc812236bc68f4e0967b1a91cda669f7801764d8d64634adcd8566d5051085b.

Check Result
Relevant core tests 1,529 pass across 12 suites
ACP Session, Channel MCP IPC and SDK MCP controller tests 933 pass across 3 suites; 2,462 core + CLI tests total
Final-bundle daemon/ACP Eight fresh scenarios pass: ordinary cancellation plus independent exit, exit on cancel, protocol corruption, disable, recovery failure/cooldown, permission denial, discovery timeout and runtime management
Counted daemon outcomes Each of six cancellation scenarios records the cancelled operation once; 53 tool calls, 16 MCP processes, all processes exited
Real source integration 12 stdio scenarios and 2 HTTP modes pass; 21 calls, 21 stdio children exited, both HTTP ports closed
HTTP response loss Reviewed code returned bare fetch failed with a recorded side effect; repaired standalone and pooled paths return the outcome warning, keep the side-effect count at one and support the next new call
Queued removal and trust revocation Unchanged real stdio probes fail on the reviewed head and pass after repair; the manager source remained unchanged through final verification
Business error 404 A real stdio counterexample failed on the first repair candidate; final production preserves the application response and call count one
Default drain budget Real removal retains capacity for the default 30-second drain; a new server is admitted after retirement, and a healthy sibling retains its reservation

The full workspace baseline build, repaired core/CLI builds, bundle, repository-wide typecheck, final core typecheck, changed-file ESLint/Prettier and diff checks pass on Node 22.22.3. The CLI compiler used a 6 GiB heap. Two final source self-review passes and two independent review passes found no additional actionable defect within the examined ownership and consumer paths. These are bounded review results, separate from hosted CI and maintainer acceptance.

The post-verification built-output manifest covers 1,061 files (118,342,322 bytes), with manifest SHA256 e6e8eba23b352510b1aa1261a97407698687c18e98bf218526e7d2d28ae8fe41; the entrypoint hash above is not a substitute for the full output tree. No rebuild occurred between daemon verification and this snapshot.

The manager approval-revocation producer and the next-send ACP declaration consumer were verified separately; this is not a single end-to-end revocation experiment. Never-settling cleanup was tested by fault injection. With the installed SDK 2.0.0, a real stdio child ignoring EOF and SIGTERM exited in about four seconds; a permanent built-in-transport cleanup hang was not reproduced. Early invalid fixture parameters and a nonexistent test directory were corrected and are not counted as production failures. The bot's previously inconclusive whole-diff test-efficacy probe is not claimed as completed by these targeted checks.

Tested on

OS Status
macOS Node 22.22.3; builds, static checks, 2,462 tests, real daemon/ACP, stdio and HTTP probes
Windows Not tested locally
Linux Not tested locally; hosted CI reports separately

Environment

Loopback daemon, WebSocket ACP, deterministic local model endpoint, counted stdio and random-port HTTP fixtures, isolated test homes and runtime directories. Raw request, cancellation, process and pool-state evidence is retained locally; results above use wire counts and actual tool outcomes, not model prose or end_turn.

Risk & Scope

  • Recovery can delay a model send. Old-entry cleanup has a five-second caller deadline; timeout reports failure and retains the barrier rather than authorizing a duplicate process. The shared five-second recovery cooldown is separate from this wait and from existing discovery timeouts. Cancellation stops that caller waiting; explicit management remains outside the cooldown.
  • Per-send declaration synchronization has a refresh cost. It reuses the existing mechanism without adding a declaration cache, management platform or generic retry framework. Initial acquisition failure and explicit closure do not become demand-recovery candidates. Trust, approval, disabled state, configuration and authentication retain their existing boundaries.
  • No breaking configuration change or migration. Ownership stays in the existing workspace pool and management surface associated with feat(serve): manage MCP through workspace runtimes #7309. Standalone invocation recovery retains its existing policy. Independent agents keep diagnostics in their owning manager/debug log.
  • Production Channel infrastructure, real OAuth providers and cross-platform process semantics are not validated locally. Preventing client replay cannot guarantee an arbitrary future model will not issue a similar business operation.
  • The review's nonblocking performance, abstraction and additional-test suggestions remain follow-up work under the repository's convergence rule. The body-only same-recipe re-add finding refers to a gate already changed in the reviewed merged head; this repair preserves that behavior rather than adding another re-add path. No review thread is marked resolved by this update.

Design documents: English · 中文. Both versions cover the same decisions, constraints, validation scenarios and evidence boundaries.

Linked Issues

Related to #11272. This implements connection recovery, not the proposed in-chat named restart command.

中文说明

本 PR 的改动

在 daemon/ACP 会话及其 Agent 的后续模型发送或资源读取前,恢复曾取得的 MCP 连接与会话工具、提示词和资源注册。恢复使用现有工作区连接池、当前权限和原有认证流程,不执行工具调用、不重放旧调用。

共享连接重启期间保留会话订阅。发现、恢复和运行时添加在会话内协调;连接池协调跨会话进程创建与旧连接清理。移除会使尚未写入配置的旧排队添加失效,排队发现开始时复查目录信任。运行时替换失败回滚本次临时内存配置,但不恢复被断开、移除或停止撤销的连接权限。

每次池化 ACP 模型发送前同步模型工具声明和提示词命令,包括恢复没有诊断提示的情况。继承工具保持来源归属,独立覆盖保留自己的连接和资源。错误处理读取所属连接:transport 缺失时在发送前拒绝,收到的 JSON-RPC 业务错误保持原义,响应丢失保留结果未知与没有重放的提示。

为什么需要

工具调用、进程/传输、会话池引用具有不同生命周期。取消一个调用可以保留健康共享进程。传输失效时,旧实现清理池条目却没有给活跃会话提供获取替代连接的路径。恢复需要重建连接归属和声明,不能推断被打断的操作可以重复执行。

本地计数服务证明这些恢复缺口,不能证明原报告中的取消机制。普通取消保留了服务进程;退出和协议损坏是受控故障。运行时配置回滚处理内存状态,不修改磁盘设置。网络错误后 transport 对象可能存在;重启中的 active 池条目也可能暂时持有断开的客户端,两种状态不能独自决定调用结果。

审查者测试计划

如何验证

  • 在本机 daemon 后配置计数 stdio 服务与两个 ACP 会话。B 正在调用时取消 A 的慢调用。B 应完成,A 的取消操作应执行一次,后续调用可用。结束进程或损坏传输后并发发送请求,要求共用一个替代进程。
  • 禁用或断开服务,要求保持断开。让替代进程启动失败,检查可见失败状态与冷却;恢复启动条件后验证后续恢复。重连后拒绝工具权限,要求 wire 上没有被拒绝的调用。发现超时不能产生工具执行。
  • 将共享重启停在初始化握手期间,让另一个会话恢复。保持 active 订阅,不能误加恢复冷却。用断开或移除打断运行时替换,检查保留配置和后续显式发现,不只检查空连接 Map。添加尚未开始时移除它,被移除服务不能启动。排队发现执行前撤销目录信任,不能启动新进程。
  • 让 HTTP 工具完成有计数的副作用后断开响应 socket,保留 SDK transport 对象。standalone 和 pooled 路径应提示结果不确定、不重放,后续新调用可用。transport 缺失的工具应提示未发送,wire 计数为零。JSON-RPC 业务错误码 404 或包含下游断线文字的错误应保留原义。
  • 验证没有恢复提示时,注册表移除能够更新下一次 ACP 模型请求的工具声明。检查继承 Agent 的工具/资源与同名独立覆盖保留归属。旧进程清理期间,获取超时应保留清理屏障;draining 进程在退出前应保留预算。

前后证据

修复提交为 fb125a154b51b227371bdcb49c916bcb1602f9d0,基于评审 head 82b635b0e58044a8345ccdf62447469e765559be,包含维护者的 main 合并。被测 CLI 入口文件 SHA256 为 1f6de4cb4679d4c47add276e433feeea657616d9d33c0d351bff720419e02ef9。相对评审 head 的五文件生产差异 SHA256 为 7cc812236bc68f4e0967b1a91cda669f7801764d8d64634adcd8566d5051085b

检查 结果
相关 core 测试 12 个套件、1,529 项通过
ACP Session、Channel MCP IPC 和 SDK MCP controller 测试 3 个套件、933 项通过;core 与 CLI 共 2,462 项
最终 bundle 的 daemon/ACP 八个新场景通过:普通取消后独立退出、取消时退出、协议损坏、禁用、恢复失败/冷却、权限拒绝、发现超时、运行时管理
daemon 调用计数 六个取消场景各记录一次取消操作;53 次工具调用、16 个 MCP 进程,进程退出
真实源集成 12 个 stdio 场景和 2 个 HTTP 模式通过;21 次调用、21 个 stdio 子进程退出、两个 HTTP 端口关闭
HTTP 响应丢失 评审代码在服务端记录副作用后返回裸 fetch failed;修复后的两种路径保留结果提示,副作用计数为一,后续新调用成功
排队删除和信任撤销 不变的真实 stdio 探针在评审 head 失败,修后通过;manager 源码在最终验收前未变化
业务错误 404 真实 stdio 反例在第一份修复候选失败;最终代码保留业务响应,调用计数为一
默认 drain 预算 真实移除在默认三十秒 drain 期间保留容量,退出后允许新服务,健康共享会话保留预算

Node 22.22.3 下,基线全工作区构建、修后 core/CLI 构建、bundle、全仓类型检查、最终 core 类型检查、变更文件 ESLint/Prettier 和 diff 检查通过。CLI 编译使用 6 GiB 堆。最终两轮源码自审和两轮独立复审,在所检查的归属及消费路径内未发现额外可处理缺陷。该结论有范围限制,区别于托管 CI 和维护者接受状态。

验收后的构建产物清单包含 1,061 个文件(118,342,322 字节),清单 SHA256 为 e6e8eba23b352510b1aa1261a97407698687c18e98bf218526e7d2d28ae8fe41;上述入口文件指纹不代替完整产物目录。daemon 验证与该快照之间没有重新构建。

审批撤销的 manager 生产端和下次发送的 ACP 声明消费端分开验证,不是一次完整的撤销端到端实验。永不完成的清理通过故障注入测试。已安装 SDK 2.0.0 的真实 stdio 进程忽略 EOF 和 SIGTERM 后约四秒退出,没有复现内置传输永久清理挂起。早期无效夹具参数与不存在的测试目录经过修正,不计入产品失败。此前 bot 未得出结论的全差异测试效能探针,不由本轮定向检查宣称完成。

测试平台

系统 状态
macOS Node 22.22.3;构建、静态检查、2,462 项测试、真实 daemon/ACP、stdio 和 HTTP 探针
Windows 未进行本地测试
Linux 未进行本地测试,托管 CI 状态另行报告

环境

本机 daemon、WebSocket ACP、确定性本地模型端点、计数 stdio 和随机端口 HTTP 夹具、隔离的测试用户目录和运行目录。原始请求、取消、进程及连接池状态证据保存在本地;上述结果依据 wire 计数和实际工具输出,不依据模型文字或 end_turn。

风险与范围

  • 恢复会延迟模型发送。旧条目清理有五秒调用者期限;超时报告失败并保留屏障,不能授权启动重复进程。共享五秒恢复冷却独立于这段等待与现有发现超时。取消结束该调用者等待,显式管理不受冷却限制。
  • 每次发送同步声明有刷新成本。复用现有机制,不新增声明缓存、管理平台或通用重试框架。首次获取失败和主动关闭不成为按需恢复候选。保留信任、审批、禁用状态、配置和认证边界。
  • 没有破坏性配置变化或迁移要求。归属保持在现有工作区池与 feat(serve): manage MCP through workspace runtimes #7309 相关管理范围。standalone 调用恢复保留现有策略。独立 Agent 的诊断留在所属 manager/调试日志。
  • 未在本地验证生产 Channel 基础设施、真实 OAuth 服务商和跨平台进程行为。客户端不重放不能保证任意模型在未来不会请求同类业务操作。
  • 按仓库收敛规则,将 review 中非阻断的性能、抽象和额外测试建议留作后续。正文中的同配置 re-add finding 对应的条件在被审查的合并 head 中已有修改;本轮保留该行为,不增加另一条 re-add 路径。本次更新不标记 review thread 为 resolved。

设计文档:English · 中文。两版覆盖相同决策、约束、验证场景与证据边界。

关联 Issue

关联 #11272。本改动实现连接恢复,不包含所提议的聊天内按名称重启命令。

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this is a well-documented one, and the design doc under docs/design/ made the review much faster.

Template looks good ✓ (all nine headings present, bilingual body).

Problem — observed, not theoretical. It links #11272 (bug(mcp): cancelling a long-running stdio tool call kills the MCP server, and it never comes back), which is open, type/bug, priority/P2, daemon. You also give baseline evidence at bb79319 with fault injection. I particularly appreciate the honest caveat that the injections reproduce connection loss but do not establish the reporter's original cancel-to-exit mechanism — this PR fixes the "and it never comes back" half, which is independently observable, and says so rather than overclaiming. Related: you used a prose reference ("关联 #11272") instead of a closing keyword, so GitHub reports no closing references. Given that scope statement, that looks deliberate and correct — please don't add Fixes.

Direction — aligned. Recovery of shared MCP connections for live daemon/ACP sessions is squarely in the daemon's mission, and the scope discipline is visible: no new route, no management surface, no generic retry framework, and #7309 / #11145 are explicitly distinguished rather than absorbed. The user docs were updated in the same PR, including the part users will actually get wrong (a lost response may hide a completed operation).

Size — core paths are touched (packages/core/src/tools/**, packages/cli/src/acp-integration/**), so the two-tier core gate applies. Breakdown of the 858 changed lines:

  • production logic: 282
  • tests: 530 (*.test.ts)
  • docs: 46 (docs/design/…, docs/users/features/mcp.md)

282 production lines is under the 500-line maintainer-awareness threshold, and the title is fix, so the Tier 1 large-refactor hard block does not apply. Tier 2 does: a core change has to be reviewable to a 100%-confidence bar, which shapes the verdict in Stage 3.

Approach — the scope feels right, and it is close to what I proposed independently before reading the diff: reflect unexpected close in the existing disconnected status, re-acquire at demand time rather than on a timer, dedupe concurrent creation behind one in-flight promise, wait for the dying entry's teardown before spawning its replacement, cool down after failure, never replay the interrupted invocation, and keep initial-discovery timeout off this path. Two things you do that I had not thought of, both good: pooledStopGeneration invalidating a reconciliation queued behind recovery, and re-applying trust/tool filters through updateConfig because they are excluded from transport identity.

Two genuine questions before the code review, neither a blocker:

  1. The first demand after a failure waits for recovery before the model send. That wait is bounded by the pool's acquire timeout, which is much longer than the five-second cooldown. Is a stalled turn on a dead server acceptable UX here, or should the first demand give up faster and let the notice carry it?
  2. Passing reconnectOnError as a third argument to withSessionConfig means the session view never matches the snapshot tool, so the "common case pays zero allocation" fast path in applyTools stops being common. Detail in Stage 2.

Risk — Stage 1e matched four high-risk paths (the revert-correlated set): packages/cli/src/acp-integration/session/Session.ts, packages/core/src/tools/mcp-client-manager.ts, packages/core/src/tools/mcp-client.ts, packages/core/src/tools/mcp-pool-entry.ts. This does not block anything, but it does mean full-depth review, no skipped enrichments, and CI evidence required before any approval. Reviewers should focus on the ordering and lifetime interactions between those four.

Moving on to code review. 🔍

中文说明

感谢贡献!这份 PR 的文档非常完整,docs/design/ 下的设计文档让审查快了很多。

模板完整 ✓(九个必需标题齐全,中英双语)。

问题——已观测到的 bug,不是理论性加固。关联 #11272bug(mcp): cancelling a long-running stdio tool call kills the MCP server, and it never comes back),该 issue 处于 open,带 type/bugpriority/P2daemon 标签。你也给出了 bb79319 基线上的故障注入证据。特别认可一处诚实的说明:注入实验复现的是连接丢失,并不能证明报告者原始的"取消导致退出"机制——本 PR 修的是"再也回不来"这一半,而这一半是可以独立观测的。另外你用的是文字引用("关联 #11272")而不是关闭关键字,所以 GitHub 没有解析出 closing reference;结合上面的范围说明,这看起来是有意为之且正确的,请不要改成 Fixes

方向——对齐。为存活的 daemon/ACP 会话恢复共享 MCP 连接,正是 daemon 的核心任务;范围克制也很明显:没有新增路由、没有管理平台、没有通用重试框架,并且明确区分了 #7309#11145 而不是把它们吸收进来。用户文档在同一个 PR 内更新,包括用户最容易误解的那一点(响应丢失可能掩盖一次已完成的操作)。

规模——触及核心路径(packages/core/src/tools/**packages/cli/src/acp-integration/**),因此适用两层核心门禁。858 行改动的拆分:

  • 生产逻辑:282
  • 测试:530 行(*.test.ts
  • 文档:46 行(docs/design/…docs/users/features/mcp.md

282 行生产代码低于 500 行的维护者关注阈值,标题是 fix,所以 Tier 1 的大规模重构硬拦截不适用。Tier 2 适用:核心改动必须达到"100% 确信"的审查标准,这会影响 Stage 3 的结论。

方案——范围合理,和我在读 diff 之前独立提出的方案高度一致:把非预期关闭反映到既有的 disconnected 状态;在需求发生时重新获取而不是用定时器;用单个 in-flight promise 合并并发创建;在启动替代进程前等待旧条目的清理;失败后冷却;绝不重放被中断的调用;初始发现超时不进入这条路径。有两点是我没想到而你们做了的,都很好:pooledStopGeneration 让排在恢复之后的配置协调失效;以及通过 updateConfig 重新应用 trust 与工具过滤,因为它们不在传输身份里。

进入代码审查前有两个真诚的问题,都不是拦截项:

  1. 失败后的第一次需求会在模型发送之前等待恢复。这个等待受池的 acquire 超时约束,比五秒冷却长得多。对一个已死亡的 server 让一轮对话卡住,这里的体验是否可接受?还是第一次需求应该更快放弃,靠提示消息承接?
  2. reconnectOnError 作为第三个参数传给 withSessionConfig,意味着 session view 永远和快照工具不匹配,于是 applyTools 里"常见情况零分配"的快路径不再常见。细节见 Stage 2。

风险——Stage 1e 命中四条高风险路径(与回滚相关的路径集合):packages/cli/src/acp-integration/session/Session.tspackages/core/src/tools/mcp-client-manager.tspackages/core/src/tools/mcp-client.tspackages/core/src/tools/mcp-pool-entry.ts。这不拦截任何东西,但意味着全深度审查、不跳过任何附加分析,并且在任何批准之前必须有 CI 证据。审查者应重点关注这四个文件之间的时序与生命周期交互。

进入代码审查 🔍

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

I read this against an independent proposal I wrote from the title and the "Why it's needed" section before opening the diff. The approach matches it closely and exceeds it in two places, so most of what follows is confirmation rather than objection. No correctness blocker found. One suggestion and two nits.

Suggestion — the zero-allocation fast path in applyTools is gone, and the test that pinned it was bent to hide that

session-mcp-view.ts now passes reconnectOnError = false as the third argument to withSessionConfig. Snapshot tools are constructed at mcp-client.ts:1693 with the true default, so the guard in withSessionConfig (reconnectOnError === this.reconnectOnError) can never match: every pooled applyTools pass now allocates a fresh DiscoveredMCPTool for every registered tool, for every session. The comment sitting directly above that call — "The helper returns the same instance when both values already match, so the common case pays zero allocation" — is now false, and the test that pinned it was retitled from "skips clone when trust matches (allocation pin)" to "skips clone when metadata and recovery ownership match", with the fixture pre-projected through withSessionConfig(true, false, false) so the pin keeps passing. A pin that only holds because the fixture was pre-cloned is not pinning production behaviour.

To be clear, I think the layering is right: McpClient.discoverTools() is shared with standalone mode, where reconnect-on-error should stay on, so the flag genuinely cannot be set at the construction site without threading pool-ness into discovery. Projecting it at the session-view boundary is the correct place. The cost is just the lost fast path, and the cheapest way to keep both is to project once when the entry seeds its snapshot (PoolEntry.seedSnapshots, around line 561) instead of once per session per pass — one clone per tool per entry rather than per tool per session per toolsChanged. If you'd rather not, then update the stale comment and drop or repurpose the pin, rather than satisfying it with a pre-cloned fixture.

Nit — optional call on a method that always exists

In stillWanted(): !this.cliConfig.isMcpServerPendingApproval?.(name). That method is declared on Config at config.ts:6573 and the test fixture provides it, so the ?. guards a case that cannot happen. AGENTS.md asks for no error handling for impossible scenarios.

Nit — recoveryRetryAfter outlives the connection it belongs to

The cooldown map is cleared on successful acquire and on drain, but not on evictEntry. A server that is later disabled, removed, or reconfigured to a new ConnectionId leaves its entry behind for the daemon's lifetime. Bounded and harmless in practice; evictEntry would be the natural place to drop it.

Also worth one line, not a finding: trackPooledConnection's stopTimedOut branch calls conn.release() bare where the code it replaced wrapped it in try/catch. Both call sites sit inside an outer try/catch, so a throw degrades into a misleading log line or a spurious "remains disconnected" notice rather than anything worse.

What I checked and believe is correct

This is a race-heavy diff, so here is what was actually verified rather than assumed:

  • The new onclose wrapper chains correctly. The constructor wrapper in mcp-client.ts captures whatever createMcpClient set; the pre-existing wrapper at mcp-client.ts:2025 then captures the constructor's as its oldOnClose. So DISCONNECTED is written exactly once per close, the unlistenDirectories cleanup still runs, and isDisconnecting still suppresses intentional disconnects. Reconnect re-wraps only the outer layer, so the new handler does not accumulate.
  • cleanupInFlight cannot reject. The chain at mcp-pool-entry.ts:441 is a two-argument .then(onFulfilled, onRejected) whose rejection branch swallows and calls updateGlobalStatus(). That matters because the pool does void cleanup.finally(...) — with a rejectable promise that would be an unhandled rejection in a long-lived daemon. It is not.
  • The pooledRecoveryInFlight / discoveryInFlight ordering is sound. recover() registers its await recovery continuation before any later discoverAllMcpToolsViaPool can register a .then on the same promise, so the finally clears both fields before a deferred reconciliation runs. Concurrent refreshes queued during recovery then dedupe on the freshly-set discoveryInFlight instead of racing. No loop, no double discovery.
  • Recovery cannot be bypassed by an alternate send path. All four ACP model-send call sites (Session.ts 5957, 6760, 9216, 10009) go through the single #sendMessageStreamWithAutoCompression helper where the hook was inserted.
  • Tools really are restored, not just declared. pool.acquire routes through attachPooledSession, which rebuilds the SessionMcpView and re-registers into the session tool registry, so the llmClient.setTools() that follows reads a genuinely refreshed registry.
  • Late results cannot override revocation. stillWanted() is re-checked after acquisition, plus stopTimedOut, plus a CONNECTED status check, plus updateConfig — and every one of those paths releases the handle instead of exposing a dead or mis-scoped connection. This is the part I would most expect a recovery PR to get wrong.
  • No replay. shouldAttemptReconnect short-circuits before the annotation logic, so pool-projected tools never take the standalone reconnect/replay path — and the flag survives all four copy paths (build, asFullyQualifiedTool, withTrust, withAppResourceUi), which the new it.each(['original','qualified','trust','app']) test asserts along with callTool being invoked exactly once.

The tests are the strongest part of this PR. They pin the invariants that matter rather than the implementation: one attempt per demand, cooldown eligibility for a later demand, cancellation stopping the wait without cancelling the shared connect, disconnect / stop / untrusted / disabled / approval / removed / changed each suppressing recovery, late-acquire release, initial discovery timeout excluded, and cleanup-before-replacement ordering via a mocked disconnect promise.

sequenceDiagram
    participant P1 as ACP Session
    participant P2 as McpClientManager
    participant P3 as McpTransportPool
    participant P4 as PoolEntry old
    participant P5 as PoolEntry replacement
    participant P6 as MCP child process
    P6->>P4: EOF or invalid frame
    P4->>P4: silent_drop sets cleanupInFlight
    P4->>P3: onClosed evicts entry
    P3->>P2: failed event
    P2->>P2: record candidate by transportId, drop stale handle
    Note over P2: no replay of the interrupted invocation
    P1->>P2: recoverFailedConnections before model send
    P2->>P2: re-check trust, disabled, approval, fingerprint
    P2->>P3: acquireForRecovery
    P3->>P4: await retiring cleanup
    alt inside five second cooldown
        P3-->>P2: throws cooling down
        P2-->>P1: notice remains disconnected
    else cooldown clear
        P3->>P5: one shared spawn serves concurrent sessions
        P5-->>P2: PooledConnection
        P2->>P2: verify CONNECTED, updateConfig, track
        P2-->>P1: notice reconnected
        P1->>P1: setTools then emit diagnostic
    end
Loading
Files changed (14 of 14 shown)
File What changed
docs/design/2026-09-08-pooled-mcp-recovery.md New design doc: reproduction, the three lifetimes, six design points, scope against PR 7309 and PR 11145, validation contract
docs/users/features/mcp.md Scopes the existing replay section to non-daemon sessions and documents the new shared daemon recovery semantics and its exclusions
packages/cli/src/acp-integration/session/Session.ts Calls recovery before the model send inside the shared auto-compression helper, refreshes tools when notices arrive, and re-checks abort
packages/cli/src/acp-integration/session/Session.test.ts Stubs the manager on the registry mock; asserts setTools happens before sendMessageStream and that cancellation during recovery sends no model request
packages/core/src/tools/mcp-client-manager.ts The bulk of the change: extracts trackPooledConnection, records failed pooled candidates, adds recoverFailedConnections with eligibility re-checks, defers config refresh behind recovery, invalidates on stop and disconnect
packages/core/src/tools/mcp-client-manager.test.ts New pooled session recovery suite covering demand recovery, revocation matrix, coalescing, failure notices, late release, cancellation
packages/core/src/tools/mcp-client.ts Wraps the SDK onclose so a clean EOF or child exit records a transport error and writes DISCONNECTED unless intentionally disconnecting
packages/core/src/tools/mcp-pool-entry.ts Stores the silent-drop sweep as cleanupInFlight, exposes waitForCleanup, adds a debug line naming the sweep reason
packages/core/src/tools/mcp-tool.ts Threads a reconnectOnError flag through the constructor, build and all three copy helpers so shared tools skip the standalone reconnect path
packages/core/src/tools/mcp-tool.test.ts Asserts recovery is left to the session across all four copy shapes, with callTool invoked exactly once
packages/core/src/tools/mcp-transport-pool.ts Adds acquireForRecovery with retiring-entry wait, shared five second cooldown and one attempt per demand; tracks retiring cleanups on close; clears cooldown on drain
packages/core/src/tools/mcp-transport-pool.test.ts New demand recovery suite: EOF observation, one replacement shared across sessions, cleanup-before-spawn ordering, cooldown across sessions, explicit acquire not throttled
packages/core/src/tools/session-mcp-view.ts Projects pool-owned tools with recovery delegated to the session
packages/core/src/tools/session-mcp-view.test.ts Adjusts the allocation pin fixture for the new third withSessionConfig argument

Testing evidence

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

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

Check Conclusion
Lint & Static (ubuntu-latest, Node 22.x) ❌ failure
Test (ubuntu-latest, Node 22.x) ❌ failure
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
macos-latest / Java 21 ✅ success
OpenTUI no-flicker gate ✅ success
Real daemon E2E / Java 11 ✅ 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,失败项排在最前。

The one red check is not this PR's. Lint & Static failed at the Prettier step on exactly one file:

Running Prettier...
Checking formatting...
[warn] packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts
[warn] Code style issues found in 1 file. Run Prettier with --write to fix.
##[error]Process completed with exit code 1.

That file is not in this PR's 14-file diff, and git log in a clean base worktree shows its most recent modification is a374d1b — which is precisely this PR's baseRefOid. So the formatting drift is inherited from main, not introduced here. I classified this from the diff and the check identity, not from anything the log body claims about itself. Practical consequence: the author cannot clear this without touching an unrelated file, and it will go away on a rebase once main is fixed. The earlier steps in that job (ESLint, actionlint, shellcheck, yamllint) all passed; the shellcheck output above the failure is warning-only and pre-existing.

Not verified, stated plainly:

  • Not verified: whether the 530 new test lines pass. Test (ubuntu-latest, Node 22.x) was still in_progress at review time, and the macOS and Windows unit jobs are skipped. I did not poll or guess an outcome.
  • Not verified: every behavioural claim in the PR description. The author reports 449 core tests, 854 Session tests, eight real daemon/ACP scenarios, and a partial npm run preflight — that is the author's claim on the author's machine, not evidence this gate re-ran, and the description itself correctly declines to claim full preflight or remote CI passed.
  • Not verified: Windows and Linux. The author tested on macOS (Darwin arm64) only, and this change is about child-process teardown and replacement, which is exactly where platform process semantics differ.
  • Not applicable: live tmux capture. This is an unattended CI run, which never drives the product; and with read-only author access the tmux lane would not be available anyway.

Sandboxed verification would settle this: @qwen-code /verify — the central claim here is a race and ordering fix in a live daemon, and none of it is observable from the diff. Specifically: that one replacement process genuinely serves two concurrent sessions, that the cancelled invocation is never replayed, that rapid failed demands hold the child count at two during the five second cooldown, and that the cleanup-before-spawn ordering holds against a real stdio child rather than a mocked connect. The pool tests stub connect and disconnect, so a green suite would not distinguish this fix from no fix. Since the author has read-only access, this would be a sponsored run: a maintainer's @qwen-code /verify comment approves the head it was written against, and that run carries a pre-execution risk screen plus a full workspace wipe before any of this PR's code executes. Please still read the resulting report with the same skepticism as the fork's own CI logs — the code under verification is adversarial input, and a crafted PR can shape what a report says even though the sandbox bounds what it can do.

中文说明

代码审查

我在打开 diff 之前,先根据标题和"为什么需要"独立写了一份方案,然后拿它对照这份 PR。两者高度一致,其中两处 PR 做得更好,所以下面大部分是确认而不是反对。没有发现正确性拦截项。 一条建议、两条小问题。

建议——applyTools 的零分配快路径消失了,而钉住它的测试被改造成看不出来

session-mcp-view.ts 现在把 reconnectOnError = false 作为第三个参数传给 withSessionConfig。快照工具在 mcp-client.ts:1693 构造时用的是默认值 true,所以 withSessionConfig 里的判断(reconnectOnError === this.reconnectOnError)永远不可能相等:每一次池化 applyTools 都会为每个已注册工具、每个会话分配一个全新的 DiscoveredMCPTool。就在那行调用上方的注释——"当两个值都已匹配时 helper 返回同一实例,因此常见情况零分配"——现在是不成立的;而钉住这一点的测试从"skips clone when trust matches (allocation pin)"改名为"skips clone when metadata and recovery ownership match",并把 fixture 预先用 withSessionConfig(true, false, false) 处理过,于是钉子继续通过。一个只因为 fixture 被预先克隆才成立的钉子,钉住的不是生产行为。

需要说清楚的是,我认为分层是对的McpClient.discoverTools() 与 standalone 模式共用,那里 reconnect-on-error 应该保持开启,所以这个标志确实无法在构造点设置,除非把"是否池化"穿透进发现流程。在 session view 边界投影是正确的位置。代价只是失去了快路径,而同时保住两者最便宜的做法是在条目播种快照时投影一次PoolEntry.seedSnapshots,约 561 行),而不是每会话每轮一次——每个条目每个工具克隆一次,而不是每个 toolsChanged 每会话每工具一次。如果不想这么改,那就更新那条过时注释,并删掉或改造这个钉子,而不是用预克隆的 fixture 让它通过。

小问题——对一定存在的方法使用可选调用

stillWanted() 里的 !this.cliConfig.isMcpServerPendingApproval?.(name)。该方法在 config.ts:6573 声明,测试 fixture 也提供了,所以 ?. 防的是不可能发生的情况。AGENTS.md 要求不为不可能的场景写错误处理。

小问题——recoveryRetryAfter 活得比它所属的连接更久

冷却表在 acquire 成功和 drain 时清理,但 evictEntry 时不清理。一个后来被禁用、移除或改配置成新 ConnectionId 的 server,会把它的条目留到 daemon 生命周期结束。实际上有界且无害;evictEntry 是清理它的自然位置。

另外值得一提,但不算 finding:trackPooledConnectionstopTimedOut 分支裸调用 conn.release(),而被它替换的代码用 try/catch 包着。两个调用点都在外层 try/catch 之内,所以抛错会退化成一条误导性日志或一次多余的"remains disconnected"提示,不会更糟。

已核实且我认为正确的部分

这是一份竞态密集的 diff,所以这里列出的是真正核实过的内容,而不是假设:

  • 新的 onclose 包装正确串联。 mcp-client.ts 构造函数里的包装捕获 createMcpClient 设置的值;mcp-client.ts:2025 既有的包装再把构造函数的包装捕获为自己的 oldOnClose。因此每次关闭只写一次 DISCONNECTED,unlistenDirectories 清理照常执行,isDisconnecting 仍然抑制主动断开。重连只重新包装外层,所以新处理器不会累积。
  • cleanupInFlight 不可能 reject。 mcp-pool-entry.ts:441 的链是双参数 .then(onFulfilled, onRejected),其 reject 分支吞掉错误并调用 updateGlobalStatus()。这一点很重要,因为池里做了 void cleanup.finally(...)——如果 promise 可 reject,那在长生命周期 daemon 里就是一次 unhandled rejection。它不是。
  • pooledRecoveryInFlightdiscoveryInFlight 的时序是可靠的。 recover() 注册其 await recovery 续体的时机,早于任何后续 discoverAllMcpToolsViaPool 能在同一个 promise 上注册 .then 的时机,所以 finally 会在延迟的协调运行之前清掉两个字段。恢复期间排队的并发刷新随后会在刚设置的 discoveryInFlight 上合并,而不是竞争。没有死循环,没有重复发现。
  • 恢复不可能被另一条发送路径绕过。 四个 ACP 模型发送调用点(Session.ts 5957、6760、9216、10009)全部经过插入钩子的那个 #sendMessageStreamWithAutoCompression helper。
  • 工具是真的被恢复,而不只是声明恢复。 pool.acquireattachPooledSession,它会重建 SessionMcpView 并重新注册进会话工具表,所以随后的 llmClient.setTools() 读到的是真正刷新过的注册表。
  • 迟到的结果无法覆盖撤销。 stillWanted() 在获取之后再次检查,外加 stopTimedOutCONNECTED 状态检查和 updateConfig——而每一条路径都会释放句柄,而不是暴露一个已死或作用域错误的连接。这正是一个恢复类 PR 最容易做错的地方。
  • 没有重放。 shouldAttemptReconnect 在注解逻辑之前短路,所以池化投影的工具永远不会走 standalone 的重连/重放路径——而且该标志在全部四条拷贝路径(buildasFullyQualifiedToolwithTrustwithAppResourceUi)中都保留,新增的 it.each(['original','qualified','trust','app']) 测试连同 callTool 恰好被调用一次一起断言了这点。

测试是这份 PR 最强的部分。它钉住的是重要的不变量而不是实现细节:每次需求只尝试一次、冷却后后续需求仍可尝试、取消只停止等待而不取消共享连接、disconnect / stop / untrusted / disabled / approval / removed / changed 每一种都抑制恢复、迟到获取被释放、初始发现超时被排除、以及通过 mock 的 disconnect promise 验证清理先于替代进程启动。

测试证据

这是一次无人值守的 CI 运行,因此按门禁规则,我没有构建、运行或执行这份 PR 树里的任何东西。下面的证据是这份 PR 自己在被审查提交 89886e994c09efad4dc340b332afc3852e70a26c 上的 CI,通过 API 读取。

唯一的红 check 不是这份 PR 造成的。 Lint & Static 在 Prettier 步骤上失败,只涉及一个文件:packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts。该文件不在这份 PR 的 14 个文件里,而在干净的基线 worktree 中 git log 显示它最近一次修改是 a374d1b——恰好就是这份 PR 的 baseRefOid。所以格式漂移是从 main 继承来的,不是这里引入的。我依据 diff 与 check 身份做此判断,而不是依据日志正文对自身行为的任何声称。实际后果:作者无法在不碰无关文件的前提下清掉它,等 main 修好后 rebase 就会消失。该 job 更早的步骤(ESLint、actionlint、shellcheck、yamllint)全部通过;失败之前的 shellcheck 输出只是 warning 且是既有问题。

明确说明未核实的部分:

  • 未核实:530 行新增测试是否通过。审查时 Test (ubuntu-latest, Node 22.x) 仍在 in_progress,macOS 与 Windows 单测 job 是 skipped。我没有轮询,也没有猜测结果。
  • 未核实:PR 描述中的任何行为性结论。作者报告了 449 个 core 测试、854 个 Session 测试、八个真实 daemon/ACP 场景以及部分完成的 npm run preflight——那是作者在其机器上的声称,不是本门禁重新运行的证据;描述本身也正确地没有声称完整 preflight 或远端 CI 已通过。
  • 未核实:Windows 与 Linux。作者只在 macOS(Darwin arm64)上测试,而这次改动涉及的正是子进程清理与替代,这恰恰是平台进程语义差异最大的地方。
  • 不适用:真实 tmux 抓取。这是无人值守 CI 运行,从不驱动产品;而且作者只有读权限,tmux 通道本来也不可用。

沙箱验证可以解决这一点:@qwen-code /verify——这里的核心结论是存活 daemon 中的竞态与时序修复,而这些都无法从 diff 观察。具体是:一个替代进程是否真的服务两个并发会话、被取消的调用是否绝不被重放、快速失败需求在五秒冷却内是否把子进程数保持在二、以及清理先于启动的时序是否对真实 stdio 子进程成立(而不是对 mock 的 connect)。池测试把 connectdisconnect 都打桩了,所以一套绿色测试无法区分"有这个修复"和"没有这个修复"。由于作者只有读权限,这将是一次受赞助的运行:maintainer 发出的 @qwen-code /verify 评论会批准它所针对的那个 head,而该运行额外带有执行前风险筛查(npm 生命周期脚本、非注册表依赖解析、包管理器配置以及模型筛查,全部 fail closed),并在执行本 PR 任何代码之前完整清空工作区。仍请以看待 fork 自身 CI 日志的同样怀疑态度阅读产出的报告:被验证的代码是对抗性输入,精心构造的 PR 可以影响报告说什么,尽管沙箱限定了它能做什么

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — I found no correctness blocker and the tests are better than most PRs in this area, but this is 282 production lines in the repo's most revert-prone paths, the unit suite had not finished, and the central claim is behavioural and unverifiable from a static read.

Going back to the proposal I wrote before opening the diff: this PR matches it nearly point for point, and beats it in two places I had not thought of — invalidating a config reconciliation queued behind recovery via a stop generation counter, and re-applying trust and tool filters after acquisition because transport identity deliberately excludes them. That second one is the kind of thing that looks like over-engineering until you notice the alternative is a recovered connection that silently ignores a permission the operator revoked mid-flight. So no, I don't have a materially simpler path to offer; the scope is what the problem actually needs, and the parts I'd have tried to cut (the design doc, the docs update) are the parts that made this reviewable at all.

What holds me at 3 rather than 4 is not the code, it's what I can't see. The whole point of this change is an ordering guarantee — old child fully torn down before its replacement spawns, one replacement shared by concurrent sessions, cancelled invocation never replayed. Every one of those is asserted by tests that stub connect and disconnect, which is the right way to unit-test it and also means a green suite cannot distinguish this fix from no fix. Layer on top: the change sits in four files that this repo's own revert history flags as high-risk, the macOS-only testing leaves Windows and Linux child-process semantics unexamined in exactly the code that manages child processes, and the core-module gate asks for 100% confidence before approving a change like this. I'm not at 100%. I'm at "this looks right and I'd want a human who owns the daemon to agree."

Two standing, non-blocking follow-ups from Stage 2 that I'd want addressed but would not block on: the lost zero-allocation fast path in applyTools together with the test that was adjusted to keep passing anyway, and the two small hygiene nits. The turn-blocking question from Stage 1 is still open for me too — a first demand after failure waits on the pool's acquire timeout, which is far longer than the five second cooldown, and I'd like to know whether a stalled turn on a dead server is the intended trade or whether the first demand should give up sooner.

On the red check: it is not this PR's. Prettier failed on one file this PR never touches, last modified by a374d1b, which is this PR's exact base commit. The author can't fix it without editing an unrelated file, and a rebase clears it once main does. Worth a maintainer's attention in its own right, since it is currently red for anyone branching from that commit.

⏸️ Deferring to a maintainer — not approving, not requesting changes. I tried to resolve an accountable owner deterministically and got nothing: $QWEN_MAINTAINER_HANDLE is unset, the PR carries no area labels at all so the owner-map matcher had nothing to match against, and there is no human reviewer yet to fall back to. Per the gate's rules I'm posting this without guessing a login rather than @mentioning the wrong person. Two concrete asks:

  1. A maintainer should make the approve/defer call on the daemon-behaviour questions above — this is a core-path change under the two-tier gate and needs a human who owns that surface.
  2. Adding the area labels this PR should have (the scope/mcp and category/tools set already on bug(mcp): cancelling a long-running stdio tool call kills the MCP server, and it never comes back (unrecoverable in Channel mode) #11272) would let owner resolution and assignment work on the next run.

A maintainer can settle the behavioural gap with a sponsored @qwen-code /verify (see Stage 2 for the exact claims it would pin). If the unit suite lands green and /verify substantiates the ordering and no-replay claims, my read moves to approve — the code earned that, the evidence just isn't here yet.

中文说明

Confidence: 3/5 —— 我没有发现正确性拦截项,测试质量也高于这个领域大多数 PR,但这是 282 行生产代码,落在本仓库最容易回滚的路径上;单测套件尚未跑完;而核心结论是行为性的,静态阅读无法验证。

回到我在打开 diff 之前写的方案:这份 PR 几乎逐点对应,并且在两处我没想到的地方做得更好——用 stop generation 计数器让排在恢复之后的配置协调失效;以及在获取之后重新应用 trust 与工具过滤,因为传输身份有意排除了它们。第二点看起来像过度设计,直到你意识到另一种选择是:一个恢复出来的连接会静默忽略运维在中途撤销的权限。所以我确实没有更简单的方案可以给;范围就是问题真正需要的,而我本来会想砍掉的部分(设计文档、用户文档更新)恰恰是让这次审查得以进行的部分。

让我停在 3 分而不是 4 分的,不是代码,而是我看不到的东西。这次改动的全部意义在于时序保证——旧子进程完全清理后才启动替代进程、并发会话共享同一个替代进程、被取消的调用绝不重放。这些全部由打桩了 connectdisconnect 的测试来断言,这既是单元测试的正确做法,也意味着一套绿色测试无法区分"有这个修复"和"没有这个修复"。再叠加:改动落在本仓库自身回滚历史标记为高风险的四个文件里;只测 macOS 意味着恰好在管理子进程的代码里没有检验 Windows 与 Linux 的子进程语义;而核心模块门禁要求对这类改动有 100% 确信才能批准。我没有到 100%。我处在"这看起来是对的,而我希望一个负责 daemon 的人同意"。

Stage 2 里有两个长期、非阻塞的跟进项,我希望处理但不会因此拦截:applyTools 中丢失的零分配快路径,以及那个被调整成照样通过的测试;还有两个小的整洁性问题。Stage 1 的阻塞轮次问题对我来说也仍未解决——失败后的第一次需求会等待池的 acquire 超时,远长于五秒冷却,我想知道让一轮对话卡在已死亡的 server 上是有意的取舍,还是第一次需求应该更早放弃。

关于红 check:它不是这份 PR 的。Prettier 失败在一个这份 PR 从未触碰的文件上,该文件最近一次修改是 a374d1b,恰好是这份 PR 的基线提交。作者无法在不修改无关文件的前提下修掉它,等 main 修好后 rebase 即可清除。它本身也值得维护者注意,因为目前任何从该提交切分支的人都会遇到它变红。

⏸️ 转交维护者——不批准,也不要求修改。 我尝试确定性地解析出负责人,但什么都没得到:$QWEN_MAINTAINER_HANDLE 未设置;这份 PR 完全没有 area 标签,所以 owner map 匹配器无从匹配;也还没有人类审查者可以兜底。按门禁规则,我选择不猜测登录名、不发 @,而不是 @ 错人。两个具体请求:

  1. 请维护者对上面的 daemon 行为问题做批准/推迟的判断——这是两层门禁下的核心路径改动,需要一个负责该界面的人。
  2. 补上这份 PR 本应有的 area 标签(bug(mcp): cancelling a long-running stdio tool call kills the MCP server, and it never comes back (unrecoverable in Channel mode) #11272 上已有的 scope/mcpcategory/tools 组合),下一次运行时负责人解析与分配就能工作。

维护者可以通过一次受赞助的 @qwen-code /verify 来解决行为性证据缺口(它具体能钉住哪些结论见 Stage 2)。如果单测套件变绿,且 /verify 证实了时序与不重放的结论,我的判断会转向批准——代码配得上这个结论,只是证据现在还不在这里。

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

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-8 recovery cooldown record not cleared on entry registration/eviction — already reported (issue comment 5587282210, qwen-triage stage 2 nit, packages/core/src/tools/mcp-transport-pool.ts:207)

Unresolved, please confirm:

  • [Critical] qwen-triage stage-3 issue comment 5587282883 (@qwen-code-ci-bot) defers the approve/defer call to a maintainer under the core-module two-tier gate — a process decision no code re-check can rule on. Its concrete code items were each re-check…

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

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": none of my checks were cut short by the ceiling, but one trace I started is unfinished and is the residual uncertainty in the second finding: I did not follow t…; "agent 1c": did not run packages/cli/src/acp-integration/acpAgent.test.ts or a full npm run typecheck — the Session-mocking check above was done statically instead.; "agent reverse-audit (round 3)": did not execute the packages/cli Session suites (they need a root npm run build for the core dist/ prerequisite); the "no other mocked config reaches the …; "agent reverse-audit (round 1)": did not execute packages/core vitest to observe the four new pool tests actually passing or the proposed witnesses actually failing — a run needs npm run bui….

中文说明

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

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

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

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"none of my checks were cut short by the ceiling, but one trace I started is unfinished and is the residual uncertainty in the second finding: I did not follow t…"agent 1c"did not run packages/cli/src/acp-integration/acpAgent.test.ts or a full npm run typecheck — the Session-mocking check above was done statically instead."agent reverse-audit (round 3)"did not execute the packages/cli Session suites (they need a root npm run build for the core dist/ prerequisite); the "no other mocked config reaches the …"agent reverse-audit (round 1)"did not execute packages/core vitest to observe the four new pool tests actually passing or the proposed witnesses actually failing — a run needs npm run bui…

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

Comment on lines +7744 to +7746
if (mcpRecoveryNotices.length > 0) {
await llmClient.setTools();
for (const notice of mcpRecoveryNotices) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-2: [certifies-falsely] [new-surface] The recovery hook runs once per model send, and this helper is called once per tool-loop lap rather than once per turn, so a server whose replacement keeps failing is re-announced and re-attempted on every lap of a single turn. The failure catch in recoverFailedConnections sets the notice but never deletes the candidate (failedPooledConnections is cleared only on success, disconnectServer, stop and releaseAllPooledConnections), so each lap re-enters recovery: inside the five-second window it re-emits the byte-identical agent_message_chunk — "remains disconnected … retry a later turn after 5 seconds", repeating the instruction to retry a later turn inside the very turn it names — and re-runs llmClient.setTools(); once the cooldown lapses it spawns another replacement child. A ten-lap turn against one dead server publishes ten identical assistant messages and roughly a dozen children, while the notice text, the design doc and docs/users/features/mcp.md ("one connection attempt per demand … no background retry loop") all promise turn granularity.

Witness:

PROBE A (one persistently failing server, three consecutive demands, real built manager):
 lap1 notices = ["MCP server 'srv' remains disconnected. Recovery failed or is cooling down;
                 retry a later turn after 5 seconds or check MCP configuration and authentication."]
 lap2 notices = [ …byte-identical… ]      lap3 notices = [ …byte-identical… ]
 lap1[0] === lap2[0] === lap3[0] (byte-identical)? true
 acquireForRecovery call count = 3
 state after 3 demands: pooled=0 failed=1 acquire=1
PROBE E (control, recovery SUCCEEDS):
 lap1 notices = ["MCP server 'srv' reconnected. Cancelled calls were not replayed."]
 lap2 notices = []                        acquireForRecovery call count = 1

Latch the diagnostic per turn rather than per model send: keep a set of already-reported notice texts on the Session, reset at turn entry, and skip both the emit and setTools() for a notice already reported this turn — while still calling recoverFailedConnections every lap so a server that recovers mid-turn is picked up.

// Session.ts, replacing the notices branch
const unseen = mcpRecoveryNotices.filter((n) => !this.#reportedMcpRecoveryNotices.has(n));
if (unseen.length > 0) {
  await llmClient.setTools();
  for (const notice of unseen) {
    await this.#emitAgentDiagnosticMessageSafely(notice, 'Failed to emit MCP recovery status');
  }
  for (const notice of unseen) this.#reportedMcpRecoveryNotices.add(notice);
}

Cross-turn retention must survive this: recoverFailedConnections returns [] on abort before draining (mcp-client-manager.ts:1848-1852), so a notice produced by a cancelled turn is still owed to the next send — pinned by expect(notices[0]).toContain('reconnected') at mcp-client-manager.test.ts:5075-5078 and stated in the design doc as "retain the notice for the next send"; and mcp-client-manager.test.ts asserts expect(f.pool.acquireForRecovery).toHaveBeenCalledTimes(2) across two demands, so suppression must drop the notice, never the retry eligibility. Key any suppression on the turn, not on the manager.

Please add a case in Session.test.ts's describe('MCP demand recovery') that drives two sends within one turn with recoverFailedConnections resolving the same notice both times and asserts that agent_message_chunk was emitted exactly once, and confirm it by removing the latch and watching that test go red.

中文说明

[Critical] R1-2:[certifies-falsely] [new-surface] 恢复钩子是按每次模型发送执行的,而这个 helper 是工具循环每一圈调用一次、不是每一轮对话一次,所以一个替代进程持续失败的 server 会在同一轮对话的每一圈被重复播报、重复尝试。recoverFailedConnections 的失败 catch 只写入提示、从不删除候选项(failedPooledConnections 仅在恢复成功、disconnectServerstopreleaseAllPooledConnections 时清理),因此每一圈都会重新进入恢复:在五秒冷却窗口内会重复发出逐字节相同的 agent_message_chunk——“remains disconnected … retry a later turn after 5 seconds”,在它自己点名的这一轮对话里反复叫用户“换一轮再试”;同时重复调用 llmClient.setTools();冷却一过就再启动一个替代子进程。一个十圈的对话面对一个已死的 server 会向助手消息流推送十条完全相同的消息、并启动约十二个子进程,而提示文案、设计文档与 docs/users/features/mcp.md(“每次需求只做一次连接尝试……没有后台重试循环”)承诺的都是轮次粒度。

证据:

PROBE A(一个持续失败的 server,连续三次需求,真实构建的 manager):
 lap1 notices = ["MCP server 'srv' remains disconnected. Recovery failed or is cooling down;
                 retry a later turn after 5 seconds or check MCP configuration and authentication."]
 lap2 notices = [ …逐字节相同… ]      lap3 notices = [ …逐字节相同… ]
 lap1[0] === lap2[0] === lap3[0] (byte-identical)? true
 acquireForRecovery call count = 3
 state after 3 demands: pooled=0 failed=1 acquire=1
PROBE E(对照组,恢复成功):
 lap1 notices = ["MCP server 'srv' reconnected. Cancelled calls were not replayed."]
 lap2 notices = []                        acquireForRecovery call count = 1

请把诊断信息按“轮次”而不是按“模型发送”去重:在 Session 上维护一个本轮已播报提示文本的集合,在轮次入口处重置,对本轮已播报的提示同时跳过发送与 setTools()——但每一圈仍然调用 recoverFailedConnections,以便对话中途恢复成功的 server 能被接上。

// Session.ts,替换 notices 分支
const unseen = mcpRecoveryNotices.filter((n) => !this.#reportedMcpRecoveryNotices.has(n));
if (unseen.length > 0) {
  await llmClient.setTools();
  for (const notice of unseen) {
    await this.#emitAgentDiagnosticMessageSafely(notice, 'Failed to emit MCP recovery status');
  }
  for (const notice of unseen) this.#reportedMcpRecoveryNotices.add(notice);
}

跨轮次保留语义必须保住:recoverFailedConnections 在 abort 时是先 return [] 再执行 drain(mcp-client-manager.ts:1848-1852),所以被取消那一轮产生的提示仍然欠着下一次发送——这一点由 mcp-client-manager.test.ts:5075-5078expect(notices[0]).toContain('reconnected') 钉住,设计文档也写明“retain the notice for the next send”;同时该测试断言两次需求下 expect(f.pool.acquireForRecovery).toHaveBeenCalledTimes(2),所以去重只能压掉提示,绝不能压掉重试资格。任何抑制都请以“轮次”为键,不要以 manager 为键。

请在 Session.test.tsdescribe('MCP demand recovery') 中补一个用例:在一轮对话内驱动两次发送、让 recoverFailedConnections 两次都返回同一条提示,断言该 agent_message_chunk 只发出一次;并通过移除去重逻辑、观察该测试变红来验证它有效。

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

Comment on lines +552 to +555
private readonly failedPooledConnections = new Map<
string,
{ transportId: ConnectionId }
>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-23: [fails-closed] [new-surface] The transportId written into failedPooledConnections and the one stillWanted() recomputes come from two different config sources, so for every stdio server added through the runtime-add route they can never be equal and this recovery path is unconditionally dead. acpAgent.ts:2637-2650 destructures cwd: _cwd out of every runtime-add config ("Runtime callers cannot … choose a process working directory"), so addRuntimeMcpServer acquires with cwd === undefined and trackPooledConnection records connectionIdOf(name, <no-cwd config>). Recovery then compares against getEffectiveMcpServers(), which runs populateMcpServerCommand(servers, cmd, getTargetDir()) and stamps cwd back onto exactly that shape (mcp-client.ts:1436-1447). stillWanted() is false, recover() returns at if (!config || !stillWanted()) return;, acquireForRecovery is never called and no notice is written at all — so neither "reconnected" nor "remains disconnected" ever reaches the model or the operator, and the dead record is re-examined on every model send for the life of the session. All 18 new tests stay green because mcp-client-manager.test.ts:26-33 mocks the module file-wide with populateMcpServerCommand: vi.fn((servers) => servers) (identity), which makes the fixture's two ids the same string by construction.

Witness:

measured against this tree's own built packages/core/dist (read-only):
 id(raw MCPServerConfig)  = srv::37685f907119e5ef
 id(effective stamped)    = srv::efe00b0f960db472   equal? false
 id(acp raw {command,args}) = srv::9fcbc71c3792774c
 id(acp effective)          = srv::3c1c6d78f28a9809 equal? false
 http id equal? true   (remote configs are not cwd-stamped)

Derive the acquired connection's id from the same config recovery will compare against — the overlay is already written at :3181 before the acquire, so acquire and compute newConnId from the effective config; alternatively normalise at the record site.

// mcp-client-manager.ts, addRuntimeMcpServer
const effective = this.getEffectiveMcpServers()[name] ?? config;
// …
const conn = await this.pool.acquire(name, effective, /* … */);

Any fix must move const newConnId = connectionIdOf(name, config); (:3052) and the idempotent-replace check if (existingConn && existingConn.transportId === newConnId) (:3077) onto the same config source as the acquire — changing only the acquire would make every re-add of an unchanged runtime server take the release-and-respawn branch instead of the replaced: false fast path.

Please add a case inside describe('pooled session recovery') that reaches the failed record through the effective config (add a stdio server via addRuntimeMcpServer, then emit { kind: 'failed' }, or build the fixture handle's transportId from the real populateMcpServerCommand via vi.importActual) and asserts acquireForRecovery was called once with a 'reconnected' notice, and confirm it by reverting the id normalisation and watching that assertion go red while every existing recovery test stays green.

中文说明

[Critical] R1-23:[fails-closed] [new-surface] 写入 failedPooledConnectionstransportIdstillWanted() 重新计算的那个来自两个不同的配置源,因此对每一个通过 runtime-add 路由添加的 stdio server,两者永远不可能相等,这条恢复路径是无条件失效的。acpAgent.ts:2637-2650 会把每个 runtime-add 配置里的 cwd: _cwd 解构掉(注释写着“Runtime callers cannot … choose a process working directory”),所以 addRuntimeMcpServer 是在 cwd === undefined 的情况下 acquire 的,trackPooledConnection 记录的是 connectionIdOf(name, <无 cwd 的配置>)。而恢复时比较的是 getEffectiveMcpServers(),它会执行 populateMcpServerCommand(servers, cmd, getTargetDir()),正好把 cwd 重新盖回这个形状上(mcp-client.ts:1436-1447)。于是 stillWanted() 为 false,recover()if (!config || !stillWanted()) return; 处返回,acquireForRecovery 根本不会被调用,也不会写入任何提示——“reconnected” 与 “remains disconnected” 都不会到达模型或运维人员,而这条死记录会在会话剩余生命周期里被每次模型发送重新检查一遍。18 个新测试全绿,是因为 mcp-client-manager.test.ts:26-33 对整个模块做了文件级 mock:populateMcpServerCommand: vi.fn((servers) => servers)(恒等),使得 fixture 里两个 id 天然就是同一个字符串。

证据:

针对本树自己构建出的 packages/core/dist 测量(只读):
 id(raw MCPServerConfig)  = srv::37685f907119e5ef
 id(effective stamped)    = srv::efe00b0f960db472   equal? false
 id(acp raw {command,args}) = srv::9fcbc71c3792774c
 id(acp effective)          = srv::3c1c6d78f28a9809 equal? false
 http id equal? true   (远端配置不会被盖上 cwd)

请让“获取连接时使用的 id”与“恢复时用来比较的配置”同源——覆盖层在 :3181 就已经写好、早于 acquire,所以可以直接用 effective 配置去 acquire 并计算 newConnId;或者在记录点做归一化。

// mcp-client-manager.ts, addRuntimeMcpServer
const effective = this.getEffectiveMcpServers()[name] ?? config;
// …
const conn = await this.pool.acquire(name, effective, /* … */);

任何修复都必须把 const newConnId = connectionIdOf(name, config);:3052以及幂等替换判断 if (existingConn && existingConn.transportId === newConnId):3077)一起迁到与 acquire 相同的配置源上——只改 acquire 会让每次“重新添加一个未变更的 runtime server”都走 release-and-respawn 分支,而不是 replaced: false 快路径。

请在 describe('pooled session recovery') 中补一个用例,让失败记录经由 effective 配置产生(通过 addRuntimeMcpServer 添加一个 stdio server 后再 emit { kind: 'failed' },或用 vi.importActual 拿到真实的 populateMcpServerCommand 来构造 fixture handle 的 transportId),断言 acquireForRecovery 被调用一次并返回 'reconnected' 提示;并通过回退 id 归一化、观察该断言变红而其余恢复测试仍全绿来验证它有效。

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

Comment on lines +1758 to +1760
conn.on('event', onFailed);
this.pooledConnections.set(name, conn);
this.failedPooledConnections.delete(name);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-12: The onFailed closure registered here is removed only by handle.release() or by the handler firing on a failed event, but readResource's self-heal at :2759-2763 drops a tracked handle with this.pooledConnections.delete(serverName) and no release() — so the closure, and the whole dead McpClientManager it captures (Config, ToolRegistry, clients, pooledConnections), stays registered on a workspace-shared PoolEntry emitter. The trigger needs no failed event: POST /workspace/mcp/<name>/restart runs doRestartsweepAndDisconnect('restart')client.disconnect(), which writes DISCONNECTED, while the pool's silent-drop listener is gated on !this.restartInProgress (mcp-pool-entry.ts:339-343), so onFailed never fires and never self-removes. A readResource landing in that window (up to the 30 s stdio discovery timeout) takes the self-heal branch, and nothing later frees it: detach() never calls internalOff, pool.releaseSession() calls entry.detach(sessionId) rather than handle.release(), and a later attach() overwrites subscriberHandles.set(sessionId, handle) without releasing the previous one. In a daemon the entry lives indefinitely while other sessions keep re-acquiring that fingerprint, so each occurrence retains one more dead manager and accumulates emitter listeners (past ten, Node emits MaxListenersExceededWarning).

Witness:

POOL layer (real McpTransportPool + real PoolEntry emitter):
 PROBE F13 :: entryEmitter listenerCount before=0 afterOn=1
              afterDropWithoutRelease=1   afterRelease=0
MANAGER layer (real McpClientManager, handle dropped by readResource):
 PRISTINE :: listenersOnHandleAfterTrack=1 trackedBefore=1
             readResource=threw: MCP server 'srv' pool entry disconnected; retry after discovery.
             releaseCalls=0 trackedAfter=0
 FIX (pooled.release() before the delete) :: releaseCalls=1 trackedAfter=0
             suite: Test Files 2 passed (2) / Tests 145 passed (145)

Release before dropping in the self-heal path, mirroring removeRuntimeMcpServer (:3301-3311), which already does exactly this:

// mcp-client-manager.ts, readResource self-heal
try {
  pooled.release();
} catch {
  // best effort
}
this.pooledConnections.delete(serverName);

release() is idempotent (mcp-pool-entry.ts:1350-1351, if (this.released) return;), so adding it here is safe even when another path already released the handle; but updateConfig throws on a released handle (mcp-pool-entry.ts:1315-1320, "Cannot update config on released MCP connection"), so the release must stay after the handle's last use in that branch. Two honest qualifications: the retained listener does not keep mutating the dead manager's maps (that mutation is behind pooledConnections.get(name) === conn, and the same invocation ends with conn.off), so retention lasts until the next failed event; and the release-less delete is itself pre-existing at the merge base — what this diff adds is the same listener registration at two more sites (the recovery path and addRuntimeMcpServer, which was a bare set() before), widening the leak surface. A maintainer may reasonably re-grade this from Critical to Suggestion on that basis; the retention is measured either way.

Please add a case in mcp-client-manager.test.ts that acquires a pooled connection whose client.getStatus() returns DISCONNECTED, calls readResource, and asserts expect(pooledConn.release).toHaveBeenCalledTimes(1) alongside the existing eviction assertion, and confirm it by removing the release() call and watching that assertion go red.

中文说明

[Critical] R1-12:这里注册的 onFailed 闭包只有两种移除方式——handle.release(),或它在一次 failed 事件中自行摘除;但 readResource 的自愈分支(:2759-2763)是用 this.pooledConnections.delete(serverName) 丢掉句柄、并不调用 release(),于是这个闭包连同它捕获的整个已失效 McpClientManagerConfigToolRegistryclientspooledConnections)会继续挂在工作区共享的 PoolEntry emitter 上。触发它并不需要 failed 事件:POST /workspace/mcp/<name>/restart 会执行 doRestartsweepAndDisconnect('restart')client.disconnect(),后者写入 DISCONNECTED,而池的 silent-drop 监听被 !this.restartInProgress 拦住(mcp-pool-entry.ts:339-343),所以 onFailed 既不会触发、也不会自行摘除。此时落在这个窗口内(最长到 stdio 的 30 秒发现超时)的 readResource 会走自愈分支,而之后没有任何路径释放它:detach() 从不调用 internalOffpool.releaseSession() 调的是 entry.detach(sessionId) 而不是 handle.release(),后续 attach() 又直接覆盖 subscriberHandles.set(sessionId, handle) 而不释放旧句柄。在 daemon 中该 entry 会长期存在(其他会话不断重新获取同一 fingerprint),因此每发生一次就多滞留一个已死 manager,并累积 emitter 监听器(超过十个时 Node 会打印 MaxListenersExceededWarning)。

证据:

POOL 层(真实 McpTransportPool + 真实 PoolEntry emitter):
 PROBE F13 :: entryEmitter listenerCount before=0 afterOn=1
              afterDropWithoutRelease=1   afterRelease=0
MANAGER 层(真实 McpClientManager,句柄被 readResource 丢弃):
 PRISTINE :: listenersOnHandleAfterTrack=1 trackedBefore=1
             readResource=threw: MCP server 'srv' pool entry disconnected; retry after discovery.
             releaseCalls=0 trackedAfter=0
 FIX(在 delete 之前调用 pooled.release()):: releaseCalls=1 trackedAfter=0
             suite: Test Files 2 passed (2) / Tests 145 passed (145)

请在自愈分支中先 release 再 delete,与 removeRuntimeMcpServer:3301-3311)保持一致——那里已经就是这么做的:

// mcp-client-manager.ts, readResource 自愈分支
try {
  pooled.release();
} catch {
  // best effort
}
this.pooledConnections.delete(serverName);

release() 是幂等的(mcp-pool-entry.ts:1350-1351if (this.released) return;),所以即使别的路径已经释放过,这里再加一次也是安全的;但 updateConfig 在已释放句柄上会抛错(mcp-pool-entry.ts:1315-1320,“Cannot update config on released MCP connection”),因此 release 必须放在该分支中句柄最后一次使用之后。两点如实说明:滞留的监听器并不会持续修改那个已死 manager 的 map(修改被 pooledConnections.get(name) === conn 拦住,且同一次调用最后会执行 conn.off),所以滞留只持续到下一次 failed 事件;另外“不 release 就 delete”本身在 merge base 上已存在——本 diff 增加的是把同一个监听器注册多带到两个位置(恢复路径,以及此前只是裸 set()addRuntimeMcpServer),从而扩大了泄漏面。基于这一点,维护者完全可以把它从 Critical 重新定级为 Suggestion;但无论定级如何,滞留本身是实测到的。

请在 mcp-client-manager.test.ts 中补一个用例:获取一个 client.getStatus() 返回 DISCONNECTED 的池化连接,调用 readResource,并在既有的 eviction 断言旁断言 expect(pooledConn.release).toHaveBeenCalledTimes(1);并通过移除该 release() 调用、观察断言变红来验证它有效。

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

Comment on lines +548 to +550
const onClose = this.client.onclose;
this.client.onclose = () => {
onClose?.();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-17: [certifies-falsely] [new-surface] This close handler has no incarnation check, so a late close event delivered by a transport that connect() already replaced is attributed to the live connection — isDisconnecting is the only guard, and connect() clears it at :562. PoolEntry.doRestartInner reuses one McpClient (sweepAndDisconnect('restart')client.disconnect()await this.client.connect()), and the installed @modelcontextprotocol/client@2.0.0 StdioClientTransport.close() only races the child's close event against 2 s, then SIGTERM + 2 s, then SIGKILL and returns without the event ever having fired; the handler registered in start() still fires whenever the process is finally reaped, which for an npx/uvx wrapper whose grandchild ignores SIGTERM is unbounded and normally lands after the restart completed. Neither Protocol.connect() nor Protocol.close() unbinds the replaced transport, so that stale event reaches Protocol._onclose() (which also drops the new _transport) and then this wrapper. The outcome is a healthy, freshly restarted server being written DISCONNECTED: if it lands after doRestart's finally clears restartInProgress, every gate in PoolEntry.statusChangeListener is satisfied, so the entry is marked failed, a failed event is emitted, every attached session is detached (all of that server's tools, prompts and resources vanish from live sessions mid-turn), the new child is SIGTERMed and the entry is evicted — the operator's restart reports failure. If it lands inside the discovery round trip the !restartInProgress gate blocks the terminal transition instead, leaving state='active' with localStatus=DISCONNECTED: readResource then throws "Client is not connected." on a working transport, and because localStatus is already DISCONNECTED a later genuine death returns at if (status === this.localStatus) return; (mcp-pool-entry.ts:292) without emitting failed — so the zombie never reaches failedPooledConnections and this PR's recovery never sees it. Pre-diff the same stale event only clobbered _transport; it wrote no status.

Witness:

real StdioClientTransport from this worktree's node_modules, child = `sh -c 'sleep 300 & wait'`:
 close() returned : +4004ms
 onclose fired?   : false     <- disconnect()'s await resolved with the event still pending
 onclose fired late: true, +5508ms after close() start, AFTER close() returned
                   (and unbounded: the grandchild was SIGKILLed manually at +1.5s; it was `sleep 300`)
real Protocol.connect, two transports, stale A.onclose fired:
 ARM A (PR as-is)  : {"wrapperFires":1,"liveTransport":"undefined (DROPPED)"}
 ARM B (fix below) : {"wrapperFires":0,"liveTransport":"B"}
SDK: src-D_zzAWoS.mjs:5740-5748 (`this._transport = transport;` then reads the NEW transport's
     onclose, never unbinds the old), :5771-5790 (_onclose -> _transport = void 0 -> onclose?.()),
     :5975-5977 (close() is only `await this._transport?.close()`), stdio.mjs:88-91 / :175-198

Neutralise the superseded transport's callbacks so a late EOF cannot reach a reconnected client — either at the top of connect() before the guard is cleared, or in disconnect() after the close settles:

// mcp-client.ts, disconnect(), after `await this.transport.close();`
this.transport.onclose = undefined;
this.transport.onerror = undefined;
this.transport.onmessage = undefined;

The unbind must not suppress the wrapper for the current transport: the silent-drop gate at mcp-pool-entry.ts:339-343 and its lastError carrier at :406 (this.client.getLastTransportError()) are the only path that turns a genuine stdio EOF into a failed event and therefore into a recovery candidate, and disconnect() still depends on this.transport being the live transport for terminateSession() + transport.close() (:808-856), so clear the old transport's callbacks without clearing the this.transport reference before :573 assigns the new one. An equivalent alternative is a per-connect() generation token that the constructor-installed handler compares.

Please add a case in mcp-client.test.ts that connects against a fake transport A, captures the onclose the SDK bound to it, disconnects, connects again against a fake transport B, then invokes A's captured stale onclose and asserts client.getStatus() is still CONNECTED and that no DISCONNECTED reached the module status registry, and confirm it by removing the unbind and watching that test go red.

中文说明

[Critical] R1-17:[certifies-falsely] [new-surface] 这个 close 处理器没有“代次”校验,因此一个由已被 connect() 替换掉的 transport 迟来投递的 close 事件,会被记到当前存活的连接上——唯一的护栏是 isDisconnecting,而 connect():562 就把它清掉了。PoolEntry.doRestartInner 复用同一个 McpClientsweepAndDisconnect('restart')client.disconnect()await this.client.connect()),而已安装的 @modelcontextprotocol/client@2.0.0StdioClientTransport.close() 只是把子进程的 close 事件与 2 秒做竞速,然后 SIGTERM 再竞速 2 秒,最后 SIGKILL 并返回——事件可能根本没有触发过start() 中注册的处理器会在进程最终被回收时才触发,对于孙子进程忽略 SIGTERM 的 npx/uvx 包装器来说这个时刻是无界的,通常落在重启完成之后Protocol.connect()Protocol.close() 都不会解绑被替换的 transport,所以这个陈旧事件会走到 Protocol._onclose()(它同时把新的 _transport 也置空),再走到这个 wrapper。结果是一个健康的、刚重启成功的 server 被写成 DISCONNECTED:如果它落在 doRestartfinally 清掉 restartInProgress 之后,PoolEntry.statusChangeListener 的每个门都满足,于是 entry 被标记 failed、发出 failed 事件、所有已挂载会话被 detach(该 server 的工具、prompt、资源在对话进行中从存活会话里全部消失)、子进程被 SIGTERM、entry 被逐出——运维看到的是一次失败的重启。如果它落在发现往返之内,!restartInProgress 门会拦住终态转换,结果是 state='active'localStatus=DISCONNECTED:此时 readResource 会在一个可用的 transport 上抛出 “Client is not connected.”,而且由于 localStatus 已是 DISCONNECTED,之后一次真实的传输死亡会在 if (status === this.localStatus) return;mcp-pool-entry.ts:292)处返回、不发 failed——于是这个僵尸连接永远进不了 failedPooledConnections,本 PR 的恢复也永远看不到它。改动之前,同样的陈旧事件只会破坏 _transport,不会写状态。

证据:

使用本 worktree node_modules 中真实的 StdioClientTransport,子进程 = `sh -c 'sleep 300 & wait'`:
 close() returned : +4004ms
 onclose fired?   : false     <- disconnect() 的 await 返回时事件仍未触发
 onclose fired late: true, close() 开始后 +5508ms,在 close() 返回之后
                   (且无界:孙子进程是在 +1.5s 被手工 SIGKILL 的;它是 `sleep 300`)
真实 Protocol.connect,两个 transport,触发陈旧的 A.onclose:
 ARM A(PR 原样)  : {"wrapperFires":1,"liveTransport":"undefined (DROPPED)"}
 ARM B(应用下述修复): {"wrapperFires":0,"liveTransport":"B"}
SDK:src-D_zzAWoS.mjs:5740-5748(`this._transport = transport;` 之后读取的是**新** transport 的
     onclose,从不解绑旧的)、:5771-5790(_onclose -> _transport = void 0 -> onclose?.())、
     :5975-5977(close() 只有 `await this._transport?.close()`)、stdio.mjs:88-91 / :175-198

请让被替换掉的 transport 的回调失效,使迟到的 EOF 无法到达已重连的 client——可以放在 connect() 顶部(清护栏之前),也可以放在 disconnect() 中 close 落定之后:

// mcp-client.ts, disconnect(),在 `await this.transport.close();` 之后
this.transport.onclose = undefined;
this.transport.onerror = undefined;
this.transport.onmessage = undefined;

解绑不能影响当前 transport 的 wrapper:mcp-pool-entry.ts:339-343 的 silent-drop 门与 :406lastError 载体(this.client.getLastTransportError())是唯一能把真实 stdio EOF 变成 failed 事件、进而变成恢复候选项的路径;并且 disconnect() 仍然依赖 this.transport 是存活 transport 来执行 terminateSession() + transport.close():808-856),所以只清 transport 的回调,不要在 :573 赋新值之前清掉 this.transport 引用本身。等价的替代方案是引入一个按 connect() 递增的 generation token,由构造函数安装的处理器做比较。

请在 mcp-client.test.ts 中补一个用例:先连到伪 transport A 并捕获 SDK 绑到它上面的 onclose,disconnect,再连到伪 transport B,然后调用捕获到的陈旧 onclose,断言 client.getStatus() 仍是 CONNECTED 且模块级状态注册表没有收到 DISCONNECTED;并通过移除解绑逻辑、观察该测试变红来验证它有效。

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

Comment on lines +532 to +534
// Shared transports are recovered by the owning session on its next
// model send. A tool's bootstrap Config must not spawn a private client.
if (!this.reconnectOnError) return false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-3: [certifies-falsely] [regression] Short-circuiting shouldAttemptReconnect here also removes the unsafe-replay warning that the same branch used to deliver, so a pool-projected tool call lost with the transport now reports a bare transport error to the model and invites it to re-issue a call whose side effect may already have landed. handleReconnectOnError throws the original error when this returns false, and UNSAFE_REPLAY_ERROR_MESSAGE ("… Do not retry automatically; verify the outcome before trying again.", :317-318) is reachable only through the !canSafelyReplay() branch below — which is now never entered. Since canSafelyReplay() requires trust === true and replayable annotations, the warning was the common case pre-diff, and SessionMcpView.applyTools now sets reconnectOnError=false for every pooled and unpooled session tool. So a model calling e.g. mcp__crm__send_invoice whose server dies mid-call receives only "Connection closed", the session's next model send recovers the server and re-registers the tool, and the model — never told the side effect may have been applied — re-issues the call and duplicates the invoice. Nothing else supplies the warning: the recovery notices go to the ACP client as agent_message_chunk and never into the model's history, and MCPCallInterruptedError is declared (mcp-pool-events.ts:131) but never thrown. The user doc this same PR adds promises exactly that guidance ("A lost response may hide a completed operation: check its outcome before requesting that operation again"), so the doc and the model-facing behaviour now disagree.

Witness:

base/PR A/B through the real projection path (SessionMcpView.applyTools -> session registry ->
build().execute()), same input both arms, an untrusted (trust: undefined) unannotated
pool-projected session tool whose callTool rejects:
 BASE (70cf363395) thrown: "MCP tool execution may have completed before the connection failed.
   Automatic replay was skipped because the call could not be verified as safe to replay.
   Do not retry automatically; verify the outcome before trying again."
   callTool=1 discoverToolsForServer=1   unsafeReplayWarningPresent=true
 PR   (a82d53f335) thrown: "Connection closed"
   callTool=1 discoverToolsForServer=0   unsafeReplayWarningPresent=false
the PR's new it.each ran 4 tests, all built with trust:true + {readOnlyHint, idempotentHint} —
the one shape where canSafelyReplay() is true; the control arm measured that shape IDENTICAL
on both sides, so the added tests cannot see this defect.

Keep the no-reconnect / no-replay decision but preserve the ambiguity signal: distinguish "not allowed to reconnect" from "not a connection error" in handleReconnectOnError — when !this.reconnectOnError and the error still matches the connection patterns (or the server status is DISCONNECTED) and !this.canSafelyReplay(), throw new Error(UNSAFE_REPLAY_ERROR_MESSAGE, { cause: error }) without calling attemptReconnect().

The fix must not re-enter attemptReconnect() — the comment right above this line says "A tool's bootstrap Config must not spawn a private client", and expect(discoverToolsForServer).not.toHaveBeenCalled() at mcp-tool.test.ts:993 pins it — and must not re-run the invocation.

Please extend mcp-tool.test.ts's 'leaves shared connection recovery to the session (%s copy)' with an unannotated (or trust: false) shared-tool case asserting the rejection message contains "verify the outcome before trying again" while discoverToolsForServer is still not called and callTool still ran exactly once, and confirm it by reverting the message and watching that case go red.

中文说明

[Critical] R1-3:[certifies-falsely] [regression] 在这里让 shouldAttemptReconnect 短路,同时也移除了同一分支原本会给出的“不安全重放”警告,于是一次随传输丢失的池化投影工具调用,现在只会向模型报一个裸的传输错误,等于邀请它重新发起一个副作用可能已经落地的调用。当这里返回 false 时,handleReconnectOnError 抛出的是原始错误,而 UNSAFE_REPLAY_ERROR_MESSAGE(“… Do not retry automatically; verify the outcome before trying again.”,:317-318)只能经由下面的 !canSafelyReplay() 分支到达——而该分支现在永远进不去了。由于 canSafelyReplay() 要求 trust === true 带可重放注解,改动之前这条警告是常见路径;而 SessionMcpView.applyTools 现在会给每一个池化与非池化的会话工具都设 reconnectOnError=false。因此当模型调用例如 mcp__crm__send_invoice、而其 server 在调用途中死掉时,它只会收到 “Connection closed”;会话的下一次模型发送会恢复该 server 并重新注册工具,而模型——从未被告知副作用可能已经执行——会再发一次调用,把发票开两次。没有别的地方补上这条警告:恢复提示是作为 agent_message_chunk 发给 ACP 客户端的,从不进入模型历史;MCPCallInterruptedError 虽有声明(mcp-pool-events.ts:131)却从未被抛出。而本 PR 新增的用户文档恰恰承诺了这条指引(“A lost response may hide a completed operation: check its outcome before requesting that operation again”),于是文档与面向模型的行为现在互相矛盾。

证据:

经由真实投影路径做 base/PR A/B(SessionMcpView.applyTools -> session registry ->
build().execute()),两侧同一输入:一个未信任(trust: undefined)、无注解的池化投影会话工具,
其 callTool 抛错:
 BASE (70cf363395) thrown: "MCP tool execution may have completed before the connection failed.
   Automatic replay was skipped because the call could not be verified as safe to replay.
   Do not retry automatically; verify the outcome before trying again."
   callTool=1 discoverToolsForServer=1   unsafeReplayWarningPresent=true
 PR   (a82d53f335) thrown: "Connection closed"
   callTool=1 discoverToolsForServer=0   unsafeReplayWarningPresent=false
PR 新增的 it.each 跑了 4 个用例,全部使用 trust:true + {readOnlyHint, idempotentHint} 构造——
正是 canSafelyReplay() 为 true 的那一种形状;对照组实测该形状两侧完全相同,
所以新增测试看不到这个缺陷。

请保留“不重连、不重放”的决定,但把“结果不确定”的信号保住:在 handleReconnectOnError 中区分“不允许重连”与“不是连接错误”——当 !this.reconnectOnError 且错误仍匹配连接类模式(或 server 状态为 DISCONNECTED)且 !this.canSafelyReplay() 时,抛出 new Error(UNSAFE_REPLAY_ERROR_MESSAGE, { cause: error })不要调用 attemptReconnect()

修复不得重新进入 attemptReconnect()——本行上方的注释写着 “A tool's bootstrap Config must not spawn a private client”,且 mcp-tool.test.ts:993expect(discoverToolsForServer).not.toHaveBeenCalled() 钉住了这一点——也不得重跑该次调用。

请为 mcp-tool.test.ts'leaves shared connection recovery to the session (%s copy)' 增加一个无注解(或 trust: false)的共享工具用例,断言拒绝消息包含 “verify the outcome before trying again”,同时 discoverToolsForServer 仍未被调用、callTool 仍恰好一次;并通过回退该消息、观察用例变红来验证它有效。

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

Comment on lines +207 to +209
retryAfter > Date.now() &&
!this.entries.has(id) &&
!this.spawnInFlight.has(id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-19: Both of these liveness-bypass conjuncts are structurally unreachable for unpooled connections, and HTTP/SSE are unpooled by default. The cooldown is keyed by the fingerprint id (connectionIdOf(name, cfg) at :200) while unpooled entries are stored under `${serverName}::unpooled-${entryIndex}` (:1205-1206) and never enter spawnInFlight at all (its only set is :449, inside the poolable branch). Since POOLED_TRANSPORTS_DEFAULT is ['stdio','websocket'], every remote server takes that path — yet PooledConnection.transportId is the fingerprint for both kinds (mcp-pool-entry.ts:263), so the manager still records the candidate and still calls acquireForRecovery. Concrete case: session A's recovery for HTTP server srv fails at t=0 and arms the cooldown to t=5 s; at t=1 s session B connects srv successfully, proving the server is up; at t=2 s it drops again and A's next send is refused with 'cooling down' — because entries.has('srv::<fingerprint>') is false while the live entry is srv::unpooled-3 — and A's turn reports "remains disconnected … retry a later turn after 5 seconds" for a failure that was never attempted. Pooled stdio servers get the bypass in the same situation; HTTP/SSE never can.

Witness:

key-space sweep + measurement against the built dist:
 mcp-pool-key.ts:38-40  POOLED_TRANSPORTS_DEFAULT = new Set(['stdio','websocket'])
 mcp-transport-pool.ts:200   const id = connectionIdOf(args[0], args[1]);        <- fingerprint
 mcp-transport-pool.ts:1205  `${serverName}::unpooled-${entryIndex}` as ConnectionId  <- entries key
 mcp-transport-pool.ts:449   this.spawnInFlight.set(id, inFlight);   <- ONLY set site, poolable branch
                             (after `throw new Error('unreachable: poolable && id === undefined')` :416)
 mcp-pool-entry.ts:263       this.transportId = connectionIdOf(serverName, cfg); <- fingerprint
 dist measurement: http id equal? true {"httpUrl":"https://example.com/mcp"} (remote cfg not cwd-stamped)
coverage verified: all five new pool tests use mkPoolOptions (test:69-78), which does not
 override pooledTransports, with MCPServerConfig('node') configs — poolable stdio only
witness: not run behaviourally — a pool-level probe needs a seeded test file and
 scratch-tree returned available: false; the sweep above is the evidence.

Make the liveness bypass key-space-agnostic — e.g. add && !this.hasNameSibling(args[0]) to the conjuncts — or record the cooldown under the same id the connection actually uses. This is a different root cause from R1-8's "clear the record on registration/eviction" fix: a fix keyed on the fingerprint id still never fires for unpooled registrations.

The bypass must reuse hasNameSibling rather than a prefix match, and the file says why at :170-183: "spawnInFlight keys have the form ${name}::${fingerprint} … Pre-fix used startsWith(\${name}::`)which produced a false positive when a sibling name BEGAN with${name}::(server names can contain::`)".

Please add a recovery case built on a non-poolable config (mkPoolOptions({ pooledTransports: new Set() }) or an httpUrl config): fail one acquireForRecovery, succeed a plain acquire, then assert a following acquireForRecovery is not rejected with 'cooling down' — deleting either conjunct keeps the current suite green, so confirm the new case reds under that mutation.

中文说明

[Suggestion] R1-19:这两个“存活性旁路”条件对非池化连接在结构上都不可达,而 HTTP/SSE 默认就是非池化的。冷却以 fingerprint id 为键(:200connectionIdOf(name, cfg)),而非池化 entry 存放在 `${serverName}::unpooled-${entryIndex}` 下(:1205-1206),并且根本不会进入 spawnInFlight(它唯一的 set:449,位于可池化分支内)。由于 POOLED_TRANSPORTS_DEFAULT['stdio','websocket'],每个远端 server 都走这条路——但 PooledConnection.transportId 对两种类型都是 fingerprint(mcp-pool-entry.ts:263),所以 manager 仍会记录候选项、仍会调用 acquireForRecovery。具体情形:会话 A 对 HTTP server srv 的恢复在 t=0 失败并把冷却武装到 t=5s;t=1s 时会话 B 成功连上 srv,证明该 server 是活的;t=2s 时它再次掉线,A 的下一次发送被以 'cooling down' 拒绝——因为 entries.has('srv::<fingerprint>') 为 false,而存活 entry 是 srv::unpooled-3——于是 A 的这一轮会为一次从未尝试过的失败报告 “remains disconnected … retry a later turn after 5 seconds”。池化 stdio server 在同样情形下能走旁路;HTTP/SSE 永远不能。

证据:

键空间普查 + 针对已构建 dist 的测量:
 mcp-pool-key.ts:38-40  POOLED_TRANSPORTS_DEFAULT = new Set(['stdio','websocket'])
 mcp-transport-pool.ts:200   const id = connectionIdOf(args[0], args[1]);        <- fingerprint
 mcp-transport-pool.ts:1205  `${serverName}::unpooled-${entryIndex}` as ConnectionId  <- entries 的键
 mcp-transport-pool.ts:449   this.spawnInFlight.set(id, inFlight);   <- 唯一 set 点,可池化分支
                             (其前有 `throw new Error('unreachable: poolable && id === undefined')` :416)
 mcp-pool-entry.ts:263       this.transportId = connectionIdOf(serverName, cfg); <- fingerprint
 dist 测量:http id equal? true {"httpUrl":"https://example.com/mcp"}(远端配置不会被盖 cwd)
覆盖情况已核实:五个新池测试都使用 mkPoolOptions(test:69-78),它没有覆盖 pooledTransports,
 且配置均为 MCPServerConfig('node') —— 只有可池化的 stdio
witness: not run behaviourally —— 池级探针需要一个可植入的测试文件,而
 scratch-tree 返回 available: false;上面的普查即为证据。

请让存活性旁路与键空间无关——例如在条件中加上 && !this.hasNameSibling(args[0])——或者用连接实际使用的同一个 id 来记录冷却。这与 R1-8 的“在注册/逐出时清记录”修复是不同的根因:以 fingerprint id 为键的修复对非池化注册仍然永远不会触发。

旁路必须复用 hasNameSibling 而不是前缀匹配,文件在 :170-183 说明了原因:“spawnInFlight keys have the form ${name}::${fingerprint} … Pre-fix used startsWith(\${name}::`)which produced a false positive when a sibling name BEGAN with${name}::(server names can contain::`)”。

请补一个基于不可池化配置的恢复用例(mkPoolOptions({ pooledTransports: new Set() }) 或一个 httpUrl 配置):先让一次 acquireForRecovery 失败,再让一次普通 acquire 成功,然后断言随后的 acquireForRecovery 不会被以 'cooling down' 拒绝——删除任一条件时现有套件仍为绿,所以请确认新用例在该变异下会变红。

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

Comment on lines +207 to +209
retryAfter > Date.now() &&
!this.entries.has(id) &&
!this.spawnInFlight.has(id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-7: These two "don't throttle when the workspace already has a transport" conjuncts are the only part of acquireForRecovery's throttle with no test — no case in the new demand recovery block ever has a cooldown record and a live entry or in-flight spawn at the same time. That is the situation an operator actually hits: session A's recovery fails and arms the cooldown, session B (or an explicit discovery/restart) then restores the shared pooled transport, and session C's next send should attach to that live entry instead of being refused. With either conjunct deleted, C gets MCP recovery for 'srv' is cooling down and the manager surfaces "remains disconnected … retry a later turn after 5 seconds" for a server the pool is already holding a CONNECTED entry for. This is the test-fidelity face of the same gate R1-19 and R1-22 report on (unreachable keys, and presence tested instead of liveness) — worth reading the three together, since a fix to the predicate changes what this test should assert.

Witness:

PRISTINE PROBE: first=threw 'transient ENOENT'  sessionC=resolved(handle)  connectCalls=2
MUTANT A (`!this.entries.has(id)` deleted):
 PROBE: first=threw  cooldownRecordStillArmed=1
        sessionC=threw "MCP recovery for 'srv' is cooling down"  connectCalls=2
 suite under MUTANT A: Test Files 3 passed (3) / Tests 208 passed (208)
   (mcp-transport-pool.test.ts 44 + session-mcp-view.test.ts 24 + mcp-client-manager.test.ts 140)
MUTANT B (`!this.spawnInFlight.has(id)` deleted): Tests 208 passed (208)
limit: no slow-spawn-in-flight + armed-cooldown state was constructed, so MUTANT B proves
 the coverage gap only — the symmetric spawnInFlight case stays unvalidated.

Add a case to describe('demand recovery'): fail one acquireForRecovery (arming the cooldown), then create a live entry for the same key without advancing timers — await pool.acquire('srv', cfg, 'a', …), the explicit path already being proven unthrottled by 'does not throttle an explicit acquire' — then assert a further acquireForRecovery('srv', cfg, 'b', …) inside the same window resolves with that entry's client instead of rejecting.

mcp-transport-pool.test.ts:280 (await expect(acquire('d')).rejects.toThrow('cooling down');) pins that an armed cooldown with no entry and no in-flight spawn still refuses, and the block runs under vi.useFakeTimers() (:167), so the new case must not relax that assertion and must not advance timers before asserting the attach — advancing 5 s would expire the record and the test would pass for the wrong reason.

The new case must go red when !this.entries.has(id) && is removed from this gate; it is the only assertion in the suite that distinguishes "cooldown armed, live entry present" from "cooldown armed, nothing present", so please confirm it that way.

中文说明

[Suggestion] R1-7:这两个“当工作区已经有传输时就不要限流”的条件,是 acquireForRecovery 限流逻辑中唯一没有测试的部分——新的 demand recovery describe 里没有任何用例同时具备“一条冷却记录”“一个存活 entry 或在途启动”。而这恰恰是运维真正会碰到的情形:会话 A 的恢复失败并武装冷却,随后会话 B(或一次显式的发现/重启)恢复了共享的池化传输,此时会话 C 的下一次发送应当挂到那个存活 entry 上,而不是被拒绝。删掉任一条件后,C 会得到 MCP recovery for 'srv' is cooling down,manager 则会为一个池里已经持有 CONNECTED entry 的 server 报出 “remains disconnected … retry a later turn after 5 seconds”。本条是同一个门(R1-19 与 R1-22 分别报告其键不可达、以及只检测存在而不检测存活)的“测试保真度”一面——三条建议放在一起读更有意义,因为对谓词的修复会改变这里应当断言的内容。

证据:

原始探针:first=threw 'transient ENOENT'  sessionC=resolved(handle)  connectCalls=2
变异 A(删除 `!this.entries.has(id)`):
 探针:first=threw  cooldownRecordStillArmed=1
        sessionC=threw "MCP recovery for 'srv' is cooling down"  connectCalls=2
 变异 A 下的套件:Test Files 3 passed (3) / Tests 208 passed (208)
   (mcp-transport-pool.test.ts 44 + session-mcp-view.test.ts 24 + mcp-client-manager.test.ts 140)
变异 B(删除 `!this.spawnInFlight.has(id)`):Tests 208 passed (208)
局限:没有构造“慢启动在途 + 冷却已武装”的状态,所以变异 B 只证明了覆盖缺口——
 对称的 spawnInFlight 用例仍未被验证。

请在 describe('demand recovery') 中补一个用例:先让一次 acquireForRecovery 失败(武装冷却),然后在不推进定时器的情况下为同一个键创建一个存活 entry——await pool.acquire('srv', cfg, 'a', …),该显式路径已由 'does not throttle an explicit acquire' 证明不受限流——再断言同一窗口内的下一次 acquireForRecovery('srv', cfg, 'b', …) 会以该 entry 的 client resolve,而不是被拒绝。

mcp-transport-pool.test.ts:280await expect(acquire('d')).rejects.toThrow('cooling down');)钉住了“冷却已武装、既无 entry 也无在途启动时仍然拒绝”,而该 describe 运行在 vi.useFakeTimers() 下(:167),所以新用例既不能放松该断言,也不能在断言 attach 之前推进定时器——推进 5 秒会让记录到期,测试就会因为错误的原因通过。

!this.entries.has(id) && 从这个门中被移除时,新用例必须变红;它是套件中唯一能区分“冷却已武装且有存活 entry”与“冷却已武装且什么都没有”的断言,请以此方式验证它有效。

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

Comment on lines +207 to +209
retryAfter > Date.now() &&
!this.entries.has(id) &&
!this.spawnInFlight.has(id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-22: This bypass tests map presence, not liveness, and forceShutdown never publishes its sweep — together those let a recovery evict a mid-sweep entry and spawn its replacement concurrently with the old child's teardown. PoolEntry.forceShutdown sets state='closed' synchronously (mcp-pool-entry.ts:781), then await this.sweepAndDisconnect(reason) (:824), and only afterwards calls this.onClosed(this.id) (:830). During that await the entry is still in pool.entries with isTerminated() true — the exact state acquire's own comment names at :330-334 — while cleanupInFlight is assigned at only one site (the silent-drop listener, :445), so waitForCleanup() returns undefined, retiringEntries is never populated and the wait at :204 is a no-op. !this.entries.has(id) is then false, so any recorded cooldown is bypassed, acquire takes its terminal-entry self-heal branch, evictEntry releases the budget slot while the dying child still holds a real OS process, and a fresh child is spawned for the same server. Trigger: a session close / disconnectServer / restart sweeping server X while another session's model send runs recovery for X — two live children for one server, one slot past --mcp-client-budget, and the invariant this diff's own comment states ("Finish that teardown before starting its replacement, even across sessions") violated on the retirement path that most often has a live child.

Witness:

mechanism verified by sweep (oracle = grep over the real source):
 cleanupInFlight — assignment sites in mcp-pool-entry.ts: 1 (line 445, the silent-drop listener)
 forceShutdown (:771-830): 0
predicate mismatch, quoted: this bypass tests presence, acquire:280 tests
 ` if (existing && !existing.isTerminated()) {`
no test covers it: forceShutdown appears in the pool tests only at :1151/:1221 (unrelated
 lifecycle cases), never concurrent with acquireForRecovery
witness: not run behaviourally — a held-sweep probe needs a seeded test file and
 scratch-tree returned available: false.
SEVERITY: filed Critical, downgraded to Suggestion on verification — both named harms are
 produced by acquire's PRE-EXISTING, unchanged self-heal branch, whose own comment says it
 deliberately mirrors "the eventual onClosed semantics", so any concurrent acquire already
 did this at the merge base. This diff's contribution is that its new wait and its new
 liveness bypass do not cover the forceShutdown retirement path. No false certification and
 no data loss, so it is not Critical on this repo's bar; the mechanism stands either way.

Make the bypass liveness-aware and cover the second retirement path:

const existing = this.entries.get(id);
if (
  this.recoveryRetryAfter.get(id) !== undefined &&
  retryAfter > Date.now() &&
  !(existing && !existing.isTerminated()) &&
  !this.spawnInFlight.has(id)
) {  }

and have forceShutdown publish its sweep the way the silent-drop listener does (assign this.cleanupInFlight = this.sweepAndDisconnect(reason) and await it) so onClosedForThisEntry populates retiringEntries for that path too.

The predicate must match the one acquire already uses, or the bypass and the fast path will disagree about the same entry — mcp-transport-pool.ts:280: if (existing && !existing.isTerminated()) {.

Please add a case that spies client.disconnect to hold the sweep open (the shape already used by 'waits for the old child cleanup before starting the replacement'), calls entry.forceShutdown('manual') without awaiting it, then asserts a concurrent pool.acquireForRecovery(…) leaves mocked.connect at its prior count until the held sweep resolves — and confirm it by deleting either half of the fix and watching it go red.

中文说明

[Suggestion] R1-22:这个旁路检测的是 map 中是否存在,而不是是否存活;同时 forceShutdown 从不发布它的清扫——两者合起来会让一次恢复逐出一个正在清扫中的 entry,并在旧子进程 teardown 的同时启动其替代进程。PoolEntry.forceShutdown 会同步设置 state='closed'mcp-pool-entry.ts:781),然后 await this.sweepAndDisconnect(reason):824),之后才调用 this.onClosed(this.id):830)。在这个 await 期间,entry 仍在 pool.entries 中且 isTerminated() 为 true——正是 acquire 自己在 :330-334 注释里点名的那种状态——而 cleanupInFlight 只在一个位置被赋值(silent-drop 监听器,:445),所以 waitForCleanup() 返回 undefinedretiringEntries 永远不会被填充、:204 的等待成为空操作。此时 !this.entries.has(id) 为 false,任何已记录的冷却都会被旁路,acquire 走它的终态 entry 自愈分支,evictEntry 在将死子进程仍持有真实 OS 进程时就释放预算名额,并为同一 server 启动一个新子进程。触发方式:一次会话关闭 / disconnectServer / 重启正在清扫 server X,而另一个会话的模型发送正在为 X 执行恢复——同一 server 出现两个存活子进程、超出一个 --mcp-client-budget 名额,并且本 diff 自己注释里写明的不变量(“Finish that teardown before starting its replacement, even across sessions”)在最常有存活子进程的那条退役路径上被违反。

证据:

机制经普查验证(oracle = 对真实源码 grep):
 cleanupInFlight —— mcp-pool-entry.ts 中的赋值点:1 处(第 445 行,silent-drop 监听器)
 forceShutdown(:771-830):0 处
谓词不一致(原文引用):本旁路检测存在性,而 acquire:280 检测的是
 ` if (existing && !existing.isTerminated()) {`
没有测试覆盖:forceShutdown 在池测试中只出现在 :1151/:1221(无关的生命周期用例),
 从未与 acquireForRecovery 并发
witness: not run behaviourally —— 挂起清扫的探针需要一个可植入的测试文件,
 而 scratch-tree 返回 available: false。
严重级别:原报告为 Critical,验证后下调为 Suggestion —— 两个被点名的危害都是由
 acquire **既有的、未改动的**自愈分支产生的,其注释本身就说明它有意模拟
 “the eventual onClosed semantics”,所以在 merge base 上任何并发 acquire 已经会这样。
 本 diff 的贡献在于:它新增的等待与新增的存活旁路没有覆盖 forceShutdown 这条退役路径。
 没有错误认证、也没有数据丢失,因此按本仓库标准不属于 Critical;但机制本身两种定级下都成立。

请让旁路具备存活性判断,并覆盖第二条退役路径:

const existing = this.entries.get(id);
if (
  this.recoveryRetryAfter.get(id) !== undefined &&
  retryAfter > Date.now() &&
  !(existing && !existing.isTerminated()) &&
  !this.spawnInFlight.has(id)
) {  }

并让 forceShutdown 像 silent-drop 监听器那样发布它的清扫(赋值 this.cleanupInFlight = this.sweepAndDisconnect(reason) 并 await 它),使 onClosedForThisEntry 在该路径上也能填充 retiringEntries

该谓词必须与 acquire 已在用的那一个一致,否则旁路与快路径会对同一个 entry 得出不同结论——mcp-transport-pool.ts:280 if (existing && !existing.isTerminated()) {

请补一个用例:用 spy 让 client.disconnect 把清扫挂住(即 'waits for the old child cleanup before starting the replacement' 已在用的形状),不 await 地调用 entry.forceShutdown('manual'),然后断言一个并发的 pool.acquireForRecovery(…) 在被挂起的清扫 resolve 之前不会增加 mocked.connect 的计数;并通过删除修复的任一半、观察它变红来验证它有效。

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

Comment on lines +224 to +228
const expiry = setTimeout(() => {
if (this.recoveryRetryAfter.get(id) === retryAfter) {
this.recoveryRetryAfter.delete(id);
}
}, 5_000);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-29: The staleness guard that keeps an older failure's expiry timer from deleting a newer cooldown for the same key has no test — deleting the if keeps the suite green. Only two tests in this file touch recoveryRetryAfter, both under vi.useFakeTimers() (test:164): 'shares failed attempts and enforces a cooldown across sessions' (:255-287) drives three concurrent failures with no time advance, so all three compute the identical Date.now() + 5_000 and every timer's === comparison is true; 'expires abandoned cooldown records without another acquire' (:289-312) creates a single record. The inequality branch — an older failure's timer declining to delete a newer record — is therefore never executed. The reachable shape is two overlapping failures for one id: fail at t=0 (record 5000, timer at t=5000), a bypassed recovery fails again at t=1000 (record 6000, timer at t=6000); without the guard the first timer deletes the record at t=5000, one second early, so a flapping server becomes retryable before its window has elapsed and each model-send lap spawns another child. The production guard itself reads correct, so this is a test-only gap.

Witness:

the guard (mcp-transport-pool.ts:224-228):
 const expiry = setTimeout(() => {
   if (this.recoveryRetryAfter.get(id) === retryAfter) { this.recoveryRetryAfter.delete(id); }
 }, 5_000);
population: only two tests touch recoveryRetryAfter, both under vi.useFakeTimers() (test:164)
 :255-287 three CONCURRENT failures, no time advance -> identical retryAfter -> every === true
 :289-312 a single record
read-only run confirms both are green today: -t 'demand recovery' -> Tests 5 passed | 39 skipped
witness: not run — the flip (remove the === comparison and re-run a new two-failure case)
 needs a scratch tree; scratch-tree returned available: false.

Add a case: fail a recovery at t=0, advance 1 s, force a second failure for the same id (e.g. re-arm via the live-entry bypass), then advance to t=5000 and assert recoveryRetryAfter still holds the second record (a further acquireForRecovery still rejects with 'cooling down') and is empty only at t=6000.

:308 and :280-284 both advance exactly 5_000 under frozen fake timers, so the new case must not relax either — and it needs a second failure at a different timestamp, which the concurrent-failure case cannot produce.

Removing the if (… === retryAfter) comparison must turn that new case red; please confirm it that way.

中文说明

[Suggestion] R1-29:那个防止“较早一次失败的到期定时器删掉同一键上更新的冷却”的陈旧性守卫没有测试——删掉这个 if,套件仍然全绿。本文件中只有两个测试涉及 recoveryRetryAfter,且都在 vi.useFakeTimers() 下(test:164):'shares failed attempts and enforces a cooldown across sessions':255-287)驱动三次并发失败且不推进时间,因此三次算出的 Date.now() + 5_000 完全相同、每个定时器的 === 比较都成立;'expires abandoned cooldown records without another acquire':289-312)只创建一条记录。于是不等分支——较早的定时器拒绝删除较新记录——从未被执行。可达的形状是同一个 id 上两次重叠的失败:t=0 失败(记录 5000,定时器在 t=5000),一次走旁路的恢复在 t=1000 再次失败(记录 6000,定时器在 t=6000);没有守卫时第一个定时器会在 t=5000 提前一秒删掉记录,于是一个抖动的 server 在窗口结束前就变为可重试,而每一圈模型发送都会再启动一个子进程。生产代码里的守卫本身是正确的,所以这只是一个测试缺口。

证据:

守卫(mcp-transport-pool.ts:224-228):
 const expiry = setTimeout(() => {
   if (this.recoveryRetryAfter.get(id) === retryAfter) { this.recoveryRetryAfter.delete(id); }
 }, 5_000);
群体:只有两个测试涉及 recoveryRetryAfter,且都在 vi.useFakeTimers() 下(test:164)
 :255-287 三次**并发**失败、不推进时间 -> retryAfter 完全相同 -> 每个 === 都成立
 :289-312 只有一条记录
只读运行确认两者今天都是绿的:-t 'demand recovery' -> Tests 5 passed | 39 skipped
witness: not run —— 翻转验证(移除 === 比较并重跑一个新的双失败用例)
 需要 scratch tree;scratch-tree 返回 available: false。

请补一个用例:在 t=0 让一次恢复失败,推进 1 秒,为同一 id 制造第二次失败(例如经由存活 entry 旁路重新武装),然后推进到 t=5000,断言 recoveryRetryAfter 仍持有第二条记录(下一次 acquireForRecovery 仍以 'cooling down' 拒绝),并且只在 t=6000 才为空。

:308:280-284 都在冻结的假定时器下恰好推进 5_000,所以新用例不能放松其中任何一个——而且它需要一次时间戳不同的第二次失败,这是并发失败用例无法产生的。

移除 if (… === retryAfter) 比较必须让该新用例变红;请以此方式验证它有效。

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

Comment on lines 760 to 762
this.draining = true;
this.recoveryRetryAfter.clear();
const deadline = Date.now() + timeoutMs;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-42: drainAll clears one of the two maps this diff adds but neither awaits nor clears the sibling retiringEntries, so a drain that starts while a silent-drop sweep is still running reports a clean drain and the daemon exits before that sweep's SIGTERMs land. An entry evicted by a silent drop is no longer in the [...this.entries.values()] snapshot (:810), and forceShutdown early-returns for terminal entries anyway (mcp-pool-entry.ts:774), so nothing in drainAll awaits retiringEntries.get(id). It resolves with {drained: 0, forced: 0, errors: []}shutdownMcpPool therefore does not even throw (acpAgent.ts:4123-4131 throws only when forced > 0 || errors.length > 0) — and drainPoolBeforeExit proceeds to runExitCleanup().finally(() => process.exit(0)) (acpAgent.ts:3087-3093), a hard exit, while listDescendantPidssigtermPidsclient.disconnect() are still pending. The wrapper's grandchildren are never signalled and outlive the daemon as orphans — precisely the leak sweepAndDisconnect exists to prevent, under the comment at acpAgent.ts:3077-3078: "Drain the workspace MCP pool BEFORE runExitCleanup so the descendant pid sweep can SIGTERM wrapper grandchildren".

Witness:

one probe, two arms, identical procedure, real McpTransportPool + real PoolEntry
(silent drop triggered through the SDK client's onerror, cleanup blocked on a controlled
client.disconnect):
 PR code : entries before drain = 0 | retiringEntries before drain = 1
           drainAll settled while cleanup pending = true
           result = {"drained":0,"forced":0,"errors":[]} | retiringEntries after drain = 1
 with fix: drainAll settled while cleanup pending = false
 (fix applied in an isolated copy, then reverted — diff -q identical to the review worktree)
baseline honesty: the leak itself predates this PR — at the merge base the same sweep was
 fired with `void this.sweepAndDisconnect('silent_drop')` and base drainAll awaited nothing
 either. What the diff adds is the tracked promise (cleanupInFlight / waitForCleanup() /
 retiringEntries) and the sibling recoveryRetryAfter.clear() inside this very method, i.e.
 the handle that would close the race. A gap in new bookkeeping, not a regression.

Snapshot the outstanding retires next to the entry snapshot, fold them into the same bounded race, and clear the map with the others:

const retires = [...this.retiringEntries.values()];
// …include Promise.allSettled(retires) in the shutdownPromises race…
this.retiringEntries.clear();

The added wait must stay inside drainAll's own budget — const deadline = Date.now() + timeoutMs; (:762, timeoutMs = opts?.timeoutMs ?? 10_000) and the caller's await agentInstance.shutdownMcpPool(8_000) (acpAgent.ts:2902), which the comment at :2893 requires to finish "before runExitCleanup".

Please add a case in mcp-transport-pool.test.ts that mocks client.disconnect to a promise the test releases later (the fixture shape already at :229), fires onerror, then calls pool.drainAll() and asserts it has not settled — or reports the entry as not drained — until the cleanup promise is released, and confirm it by removing the new await and watching that assertion go red.

中文说明

[Suggestion] R1-42:drainAll 清掉了本 diff 新增两个 map 中的一个,却既没有 await 也没有清理另一个 retiringEntries,因此一次在 silent-drop 清扫仍在运行时开始的 drain 会上报“已干净排空”,而 daemon 会在该清扫的 SIGTERM 落地之前退出。被 silent drop 逐出的 entry 已经不在 [...this.entries.values()] 快照里(:810),而 forceShutdown 对终态 entry 本来就会提前返回(mcp-pool-entry.ts:774),所以 drainAll 中没有任何东西会 await retiringEntries.get(id)。它以 {drained: 0, forced: 0, errors: []} 结束——因此 shutdownMcpPool 甚至不会抛错(acpAgent.ts:4123-4131 只在 forced > 0 || errors.length > 0 时抛)——随后 drainPoolBeforeExit 继续执行 runExitCleanup().finally(() => process.exit(0))acpAgent.ts:3087-3093),一次硬退出,而此时 listDescendantPidssigtermPidsclient.disconnect() 仍在挂起。包装器的孙进程从未收到信号,会作为孤儿进程活得比 daemon 更久——这正是 sweepAndDisconnect 存在的目的所要防止的泄漏,见 acpAgent.ts:3077-3078 的注释:“Drain the workspace MCP pool BEFORE runExitCleanup so the descendant pid sweep can SIGTERM wrapper grandchildren”。

证据:

一个探针、两个分支、流程完全相同,使用真实 McpTransportPool + 真实 PoolEntry
(通过 SDK client 的 onerror 触发 silent drop,清理被一个受控的 client.disconnect 阻塞):
 PR 代码 :drain 前 entries = 0 | drain 前 retiringEntries = 1
           drainAll settled while cleanup pending = true
           result = {"drained":0,"forced":0,"errors":[]} | drain 后 retiringEntries = 1
 应用修复:drainAll settled while cleanup pending = false
 (修复在隔离副本中应用,随后回退——diff -q 与审查 worktree 完全一致)
基线如实说明:泄漏本身早于本 PR —— 在 merge base 上,同一次清扫是用
 `void this.sweepAndDisconnect('silent_drop')` 发出的,base 的 drainAll 也什么都不 await。
 本 diff 增加的是可跟踪的 promise(cleanupInFlight / waitForCleanup() / retiringEntries)
 以及就在本方法内的姊妹调用 recoveryRetryAfter.clear(),也就是本可用于关闭该竞态的抓手。
 这是新增记账中的缺口,不是回归。

请在 entry 快照旁边把未完成的退役一并快照下来,折进同一个受限竞速,并与其他 map 一起清理:

const retires = [...this.retiringEntries.values()];
// …把 Promise.allSettled(retires) 纳入 shutdownPromises 的竞速…
this.retiringEntries.clear();

新增的等待必须留在 drainAll 自己的预算之内——const deadline = Date.now() + timeoutMs;:762timeoutMs = opts?.timeoutMs ?? 10_000)以及调用方的 await agentInstance.shutdownMcpPool(8_000)acpAgent.ts:2902),而 :2893 的注释要求它必须“before runExitCleanup”完成。

请在 mcp-transport-pool.test.ts 中补一个用例:把 client.disconnect mock 成一个由测试稍后释放的 promise(:229 已有的 fixture 形状),触发 onerror,然后调用 pool.drainAll(),断言在清理 promise 被释放之前它没有结束——或上报该 entry 未被排空;并通过移除新增的 await、观察该断言变红来验证它有效。

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

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

13 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-11 disconnectServer's recoveryNotices delete unpinned — already reported (comment 3962919838)
  • R1-14 late-window eligibility recheck missing trust-revocation/disable arms — already reported (comment 3962919800)
  • R1-16 session-scoped registries to pool unpinned, incl. the measured replay-into-the-right-registry arm — already reported (comment 3962919824)
  • R1-28 vacuous 'authentication' notice assertion — already reported (comment 3962919796)
  • R1-29 cooldown expiry-timer identity guard unpinned — already reported (comment 3962919864)
  • R1-30 blocking recovery wait after goalTurn.modelStarted (phantom goal iteration) — already reported (comment 3962919767)
  • R1-31 mcp-client-update emits unpinned (fixture has no eventEmitter) — already reported (comment 3962919788)
  • R1-32 recovered connection never re-failed; onFailed re-arm unpinned — already reported (comment 3962919794)
  • R1-35 close-detection path records no cause in the status registry — already reported (comment 3962919839)
  • R1-37 cancellation case does not pin the post-recovery guard — already reported (comment 3962919763)
  • R1-43 autonomous (cron/notification) loops pay the blocking recovery wait — already reported (comment 3962919768)
  • R1-45 stopTimedOut guard untested at all three call sites — already reported (comment 3962919807)
  • R1-46 fixture derives id and transportId from the same expression — already reported (comment 3962919777)

Unresolved, please confirm:

  • [Critical] qwen-triage stage-3 issue comment 5587282883 — defers the approve/defer call to a maintainer under the core-module two-tier gate; a process decision no code re-check can rule on (its concrete code items were each re-checked in round 1)

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

Not explored to full depth (tool budget reached): "agent reverse-audit (round 3)": none — I did not walk mcp-transport-pool.ts 's acquireForRecovery /cooldown internals or Session.ts 's send-path wiring beyond confirming call sites; those a…; "agent reverse-audit (round 1)": confirming whether the installed MCP SDK's StdioClientTransport.close() / streamable-HTTP close() can in fact never settle — node_modules/@modelcontextprot…; "agent reverse-audit (round 3)": whether the background-notification turn's promptId (Session.ts:9945) is unique per turn — the turn-dedup conclusion for that send site rests on its analogy w…; "agent reverse-audit (round 3)": whether the pool imposes any connect deadline underneath acquireForRecovery , which is what would settle whether the design doc's "stdio discovery defaults to …; "agent reverse-audit (round 3)": docs/design structural requirements beyond AGENTS.md's English/Chinese rule — docs/design/README.md, which AGENTS.md links, is not readable in this worktree ( F…, and 3 more.

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

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

  • packages/core/src/tools/mcp-client-manager.test.ts:4897 — [probe] recoveryNotices drain-once contract unpinned — deleting the clear() at mcp-client-manager.ts:1885 keeps 31 tests green (code unchanged since round 1; deferred by the code-age…
  • packages/core/src/tools/mcp-client-manager.test.ts:5215 — [probe] recovery-side discoveryInFlight barrier (:1798) has no witness — the precise swap mutation keeps 31 tests green (code unchanged since round 1; deferred by the code-age rule)
  • packages/core/src/tools/mcp-client-manager.test.ts:5267 — [probe] failure-side late-supersede guard (:1846) unwitnessed — deleting it keeps all MCP tests green and yields a phantom retry notice after a deliberate disconnect (code unchanged …

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

[Critical] R2-16: [regression] The constructor-level onclose wrapper (mcp-client.ts:548-555) writes DISCONNECTED for EVERY McpClient including legacy standalone (pool-disabled) ones — at the merge base a clean SDK close never updated status (base blob 3b25217cdb has no wrapper). Its un-nominated consumer is the default-on health monitor (DEFAULT_HEALTH_CONFIG { checkIntervalMs: 30000, maxConsecutiveFailures: 3, autoReconnect: true, reconnectDelayMs: 5000 }, mcp-client-manager.ts:63-68): after three non-CONNECTED polls it calls reconnectServer, which on success resets the counter and re-arms — no attempt cap anywhere — so a cleanly-exited stdio server becomes an unbounded background respawn loop (~one process every 95s, full tool re-registration each time) in the interactive CLI and in QWEN_SERVE_NO_MCP_POOL=1 daemons, contradicting this diff's own docs ('Disabling the pool retains the legacy per-session connection behavior', docs/users/features/mcp.md:306; 'no background reconnect timers', design doc). Witness (probe): status after clean SDK onclose = disconnected; legacy monitor over ~30 polls — pre-diff CONNECTED arm respawns = 0 vs post-diff DISCONNECTED arm respawns = 9 (deterministic split, count bounded only by elapsed time). Pool mode is exempt ('if (!this.clients.has(serverName)) return;', :2136-2138), so this lands exactly on the population the docs describe as unchanged. Fix: scope the status write to pool-managed clients, or bound the legacy consumer with a max-reconnect-attempt cap plus backoff; align the legacy-behavior doc claim either way. A maintainer may rule the awakened monitor intended — the measured harm is the unbounded, uncapped respawn of a deliberately-stopped server. (Inline anchor withheld by the positional overlap gate: every anchorable line in the wrapper collides with adjacent existing threads — R1-17 at :550, a different claim fixed this round; R1-35 at :554, a different claim still open.)

中文说明

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

本轮确认的 13 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

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

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)"none — I did not walk mcp-transport-pool.ts 's acquireForRecovery /cooldown internals or Session.ts 's send-path wiring beyond confirming call sites; those a…"agent reverse-audit (round 1)"confirming whether the installed MCP SDK's StdioClientTransport.close() / streamable-HTTP close() can in fact never settle — node_modules/@modelcontextprot…"agent reverse-audit (round 3)"whether the background-notification turn's promptId (Session.ts:9945) is unique per turn — the turn-dedup conclusion for that send site rests on its analogy w…"agent reverse-audit (round 3)"whether the pool imposes any connect deadline underneath acquireForRecovery , which is what would settle whether the design doc's "stdio discovery defaults to …"agent reverse-audit (round 3)"docs/design structural requirements beyond AGENTS.md's English/Chinese rule — docs/design/README.md, which AGENTS.md links, is not readable in this worktree ( F…,另有 3 条。

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

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

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

[Critical] R2-16: [regression] The constructor-level onclose wrapper (mcp-client.ts:548-555) writes DISCONNECTED for EVERY McpClient including legacy standalone (pool-disabled) ones — at the merge base a clean SDK close never updated status (base blob 3b25217cdb has no wrapper). Its un-nominated consumer is the default-on health monitor (DEFAULT_HEALTH_CONFIG { checkIntervalMs: 30000, maxConsecutiveFailures: 3, autoReconnect: true, reconnectDelayMs: 5000 }, mcp-client-manager.ts:63-68): after three non-CONNECTED polls it calls reconnectServer, which on success resets the counter and re-arms — no attempt cap anywhere — so a cleanly-exited stdio server becomes an unbounded background respawn loop (~one process every 95s, full tool re-registration each time) in the interactive CLI and in QWEN_SERVE_NO_MCP_POOL=1 daemons, contradicting this diff's own docs ('Disabling the pool retains the legacy per-session connection behavior', docs/users/features/mcp.md:306; 'no background reconnect timers', design doc). Witness (probe): status after clean SDK onclose = disconnected; legacy monitor over ~30 polls — pre-diff CONNECTED arm respawns = 0 vs post-diff DISCONNECTED arm respawns = 9 (deterministic split, count bounded only by elapsed time). Pool mode is exempt ('if (!this.clients.has(serverName)) return;', :2136-2138), so this lands exactly on the population the docs describe as unchanged. Fix: scope the status write to pool-managed clients, or bound the legacy consumer with a max-reconnect-attempt cap plus backoff; align the legacy-behavior doc claim either way. A maintainer may rule the awakened monitor intended — the measured harm is the unbounded, uncapped respawn of a deliberately-stopped server. (Inline anchor withheld by the positional overlap gate: every anchorable line in the wrapper collides with adjacent existing threads — R1-17 at :550, a different claim fixed this round; R1-35 at :554, a different claim still open.)

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

// owns the eviction, we just close the observability gap on
// the read path.
if (pooled.client.getStatus() !== MCPServerStatus.CONNECTED) {
pooled.release();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-12: (fix-induced) [fails-closed] [regression] The pooled.release() added to readResource's self-heal — the line that closed the previously reported handle/listener leak — fires on ANY non-CONNECTED status, including the transient window of an intentional PoolEntry.restart(), where restartInProgress suppresses the failed event (mcp-pool-entry.ts:339-343). The release detaches the session's view (entry.detach(sessionId)view.teardown() removes the server's tools/prompts/resources from the live session) and internalOffs the onFailed listener, but the branch records no failedPooledConnections candidate — so recoverFailedConnections early-returns on size === 0 forever, and the completed restart fans out only to the remaining subscribers, which no longer include this session. One model resource read during a restart window (ReadMcpResource / @resource → tool-registry.ts:1085) therefore strips the server from the session permanently, for the daemon's life — worse than pre-fix, when the branch deleted the handle but left the view attached and the restart fan-out restored the registrations. If the session held the only ref, refs.size === 0 also arms the drain timer (or forceShutdown('manual') for unpooled entries) on an entry that is mid-successful-restart.

Witness:

PROBE (real manager+pool, entry non-CONNECTED, no 'failed' event):
readResource threw: "MCP server 'srv' pool entry disconnected; retry after discovery."
initial.release calls = 1 | pooledConnections has srv = false
failedPooledConnections.size = 0 | recoverFailedConnections -> [] | acquireForRecovery calls = 0
CONTROL (same fixture, entry emits 'failed'):
failedPooledConnections.size = 1 -> notices ["MCP server 'srv' reconnected. ..."] | acquireForRecovery = 1
Provenance: git diff <prev-round>..HEAD --unified=0 -> @@ -2759,0 +2801 @@  +  pooled.release();

Record the candidate in the same branch so the next model send restores the session through the pool:

if (pooled.client.getStatus() !== MCPServerStatus.CONNECTED) {
  const failedTransportId = pooled.transportId;
  pooled.release();
  this.pooledConnections.delete(serverName);
  this.failedPooledConnections.set(serverName, {
    transportId: failedTransportId,
  });
  throw new Error(/* unchanged */);
}

The fix must not violate an existing premise: stillWanted() re-derives connectionIdOf(name, this.getEffectiveMcpServers()[name] ?? {}) and compares it against failure.transportId (mcp-client-manager.ts:1808-1812), so the recorded id must be the entry's transportId (mcp-pool-entry.ts:263), not the handle's id.

Please extend 'releases a disconnected resource handle even without a failed event' (mcp-client-manager.test.ts ~:4956) to call recoverFailedConnections after the rejection and assert one acquireForRecovery call plus a 'reconnected' notice, and confirm it by removing the candidate recording and watching that test go red.

中文说明

[Critical] R1-12:(修复引入)[fails-closed] [regression] 为关闭上一轮报告的句柄/监听器泄漏而新增到 readResource 自愈分支的 pooled.release(),会在任何非 CONNECTED 状态下触发——包括有意 PoolEntry.restart() 的瞬态窗口(此时 restartInProgress 会抑制 failed 事件,mcp-pool-entry.ts:339-343)。release 会_detach 会话视图(entry.detach(sessionId)view.teardown() 把该 server 的 tools/prompts/resources 从活跃会话移除)并 internalOffonFailed 监听器,但该分支不记录 failedPooledConnections 候选项——于是 recoverFailedConnections 永远在 size === 0 处提前返回,重启成功后的扇出也只发给剩余 subscribers(已不含本会话)。重启窗口内的一次模型资源读取(ReadMcpResource / @resource → tool-registry.ts:1085)就会把该 server 从会话中永久剥离,直到 daemon 生命周期结束——比修复前更糟:修复前该分支只删除句柄、保留视图挂接,重启扇出会恢复注册。若本会话持有唯一 ref,refs.size === 0 还会对一个正在成功重启中的 entry 启动 drain 定时器(非池化则直接 forceShutdown('manual'))。

证据:

PROBE(真实 manager+pool,entry 非 CONNECTED、无 'failed' 事件):
readResource threw: "MCP server 'srv' pool entry disconnected; retry after discovery."
initial.release calls = 1 | pooledConnections has srv = false
failedPooledConnections.size = 0 | recoverFailedConnections -> [] | acquireForRecovery calls = 0
CONTROL(同 fixture,entry 发出 'failed'):
failedPooledConnections.size = 1 -> notices ["MCP server 'srv' reconnected. ..."] | acquireForRecovery = 1
来源:git diff <上一轮>..HEAD --unified=0 -> @@ -2759,0 +2801 @@  +  pooled.release();

请在同一分支记录候选项,让下一次模型发送通过池恢复会话(代码见上方英文部分)。修复不得违反的既有前提:stillWanted()connectionIdOf(name, this.getEffectiveMcpServers()[name] ?? {})failure.transportId 比较(mcp-client-manager.ts:1808-1812),因此记录的必须是 entry 的 transportId(mcp-pool-entry.ts:263),不是句柄的 id。请扩展 'releases a disconnected resource handle even without a failed event'(mcp-client-manager.test.ts 约 :4956):在 rejection 之后调用 recoverFailedConnections,断言 acquireForRecovery 恰好一次且提示包含 'reconnected';并通过移除候选记录、观察该测试变红来验证。

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

}
const recover = async () => {
if (this.failedPooledConnections.size === 0) return;
while (this.discoveryInFlight) await this.discoveryInFlight;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-1: [fails-closed] [new-surface] recoverFailedConnections has uncontained throws outside its per-server try, and its only production consumer — the pre-send block at Session.ts:7732-7735 — awaits it (then llmClient.setTools()) with no try/catch, so an MCP bookkeeping failure aborts the user's turn before any model request. Three measured reject sources: this bare await this.discoveryInFlight (the identical await is wrapped in try/catch by stop() at :1970-1988, with a log recording that rejections there were previously swallowed); stillWanted()'s non-optional-chained isMcpServerPendingApproval(name) at :1810 — the file's only unchained call, while :1120/:1316/:1630/:1670/:2350/:2855/:2939 all use ?. behind the comment "Optional-chain is defensive for the daemon surface where 'isMcpServerPendingApproval' may be absent"; and the plain-EventEmitter emit tail at :1861 (unlike PoolEntry.emit's per-listener try/catch). The rejection escapes into the send-path catch at Session.ts:7149 and fails the turn as if the model provider had errored — no model reply, no MCP diagnostic. The compression attempt immediately below the recovery block IS wrapped, and every other best-effort setTools() caller guards it (config.ts:3943, tool-search.ts:359-372).

Witness:

PROBE ARM A (candidate recorded; settings refresh makes mcpServerCommand unparseable, discovery in flight):
  discoverAllMcpTools -> REJECTED "failed to parse mcpServerCommand: node a && node b"
  recoverFailedConnections -> REJECTED (same) | acquireForRecovery = 0
CONTROL (well-formed command): RESOLVED ["MCP server 'srv' reconnected. ..."] | acquireForRecovery = 1
PROBE (discoveryInFlight = Promise.reject): recoverFailedConnections -> REJECTED
PROBE (throwing 'mcp-client-update' listener): REJECTED "listener blew up" — recovery itself SUCCEEDED (acquireForRecovery = 1)
PROBE (Config without isMcpServerPendingApproval): REJECTED TypeError: ...is not a function

Make the whole path best-effort: contain recover()'s body so any throw falls through to the existing notices/[] return; use isMcpServerPendingApproval?.(name) at :1810 to match :1630; move the pre-try stillWanted() inside the per-server try; and defensively wrap the Session.ts recovery block in try/catch that logs and proceeds.

The fix must not violate: the abort early-return at Session.ts:7736-7738 must keep returning stopReason: 'cancelled' (pinned by Session.test.ts:16124 'does not send a model request after cancellation during recovery'), and an aborted recovery must still resolve [] (pinned by 'cancellation stops waiting without cancelling the shared recovery or replaying').

Please add a Session.test.ts 'MCP demand recovery' case with recoverFailedConnections.mockRejectedValue(...) asserting mockChat.sendMessageStream is still called and the turn does not settle as an error, plus a manager case with a rejecting discoveryInFlight asserting recoverFailedConnections resolves rather than rejects — and confirm each by removing the corresponding guard and watching it go red.

中文说明

[Critical] R2-1:[fails-closed] [new-surface] recoverFailedConnections 在其 per-server try 之外存在未被收容的抛出点,而它唯一的生产调用方——Session.ts:7732-7735 的发送前恢复块——对它的 await(以及随后的 llmClient.setTools())没有任何 try/catch,于是一次 MCP 记账失败会在模型请求发出之前中止用户轮次。三个已实测的拒绝源:本行的裸 await this.discoveryInFlightstop() 在 :1970-1988 对同一 await 包了 try/catch,并记录过此处拒绝曾被吞掉);:1810 stillWanted() 中未加可选链的 isMcpServerPendingApproval(name)——全文件唯一未加链的调用点,其余 :1120/:1316/:1630/:1670/:2350/:2855/:2939 都用 ?.,且 :1629 注释写明“daemon 面上该方法可能不存在”;以及 :1861 普通 EventEmitter 的 emit 尾部(不同于 PoolEntry.emit 的逐监听器 try/catch)。拒绝会逃入 Session.ts:7149 的发送 catch,把轮次当成模型供应商错误失败掉——没有模型回复,也没有 MCP 诊断。紧随其后的压缩尝试是有包裹的,其他所有 best-effort 的 setTools() 调用方也都有守卫(config.ts:3943、tool-search.ts:359-372)。

证据:

PROBE ARM A(已记录候选;设置刷新使 mcpServerCommand 不可解析,发现流程在飞):
  discoverAllMcpTools -> REJECTED "failed to parse mcpServerCommand: node a && node b"
  recoverFailedConnections -> REJECTED(同因) | acquireForRecovery = 0
CONTROL(命令合法): RESOLVED ["MCP server 'srv' reconnected. ..."] | acquireForRecovery = 1
PROBE(discoveryInFlight = Promise.reject): recoverFailedConnections -> REJECTED
PROBE('mcp-client-update' 监听器抛错): REJECTED "listener blew up"——恢复本身成功(acquireForRecovery = 1)
PROBE(Config 缺少 isMcpServerPendingApproval): REJECTED TypeError: ...is not a function

请把整条路径改为 best-effort:收容 recover() 主体,让任何抛出落到既有的 notices/[] 返回;:1810 改用 isMcpServerPendingApproval?.(name) 与 :1630 一致;把 try 之外的 stillWanted() 移入 per-server try;并在 Session.ts 的恢复块外加防御性 try/catch(记录日志后继续)。修复不得违反:Session.ts:7736-7738 的 abort 提前返回必须继续返回 stopReason: 'cancelled'(由 Session.test.ts:16124 钉住);被中止的恢复必须仍然 resolve [](由 'cancellation stops waiting…' 钉住)。请补两个用例(Session 侧 mockRejectedValue 断言仍到达 sendMessageStream;manager 侧 discoveryInFlight 拒绝断言 resolve 而非 reject),并通过移除对应守卫观察变红来验证。

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

this.cliConfig.getResourceRegistry(),
);
// Disconnect/stop/config revocation wins over a late acquire.
if (!stillWanted() || this.stopTimedOut) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-2: [certifies-falsely] [new-surface] This "late acquire lost" release is not handle-identity scoped: conn.release() resolves to pool.release(id, sessionId)entry.detach(sessionId) (mcp-transport-pool.ts:558-562), which tears down whichever SessionMcpView currently occupies that session's seat — not the released handle's own view. The stillWanted() guard fires precisely BECAUSE another path superseded this recovery, so the release destroys the superseding connection. addRuntimeMcpServer is the unserialized partner (its pool.acquire at :3243 has no discoveryInFlight/pooledRecoveryInFlight gate), and this diff's effectiveConfig fingerprint unification (:3094-3099/:3243) makes the two acquires land on the SAME ConnectionId — pre-diff the raw-config fingerprint differed and they hit different entries. The failure chain: recovery in flight (up to the 30 s stdio window) → runtime re-add attaches view2 and trackPooledConnection deletes the candidate → recovery's continuation sees stillWanted() === falseH1.release()view2.teardown() purges the just-added server's registrations from the live session → at refs.size === 0 the drain timer fires forceShutdown, which emits 'disconnected', NOT 'failed' (:1775 ignores it) — so no candidate is recorded and demand recovery never restores it. The runtime-add route already returned success with toolCount, and pooledConnections[X] keeps a handle to a killed entry. The same non-identity release exists at :1719-1722.

Witness:

PROBE (real pool + entry; two concurrent acquires, same (name, cfg, session), two registry pairs):
distinct handles = true | same connection id = true
after h1.release(): refs = [] | r2.removeMcpToolsByServer = 2  <- the CURRENT seat's view torn down
entry state = draining -> closed after drainDelay | connect calls = 1
with an identity-scoped release (candidate fix):
refs = ['a'] | r2.removeMcpToolsByServer = 1 | entry state = active

Make release identity-scoped: thread the handle (or a per-attach token) through attach's release callback so PoolEntry.detach no-ops when the seat belongs to a different handle; and serialize addRuntimeMcpServer's acquire against pooledRecoveryInFlight/discoveryInFlight the way discoverAllMcpToolsViaPool already does.

The fix must not violate: McpTransportPool.release(id, sessionId) (:558-562) has no handle parameter and releaseSession(sessionId) (:577-593) bulk-detaches with no identity at all — an identity-scoped detach must keep a no-identity default path, or acpAgent.killSession leaks refs for killed sessions.

Please add a pool test that acquires the same (serverName, cfg, sessionId) twice (two handles, one seat), releases the FIRST handle, and asserts the entry still holds the session (refs.has(sessionId), the second view still projects, no drain timer fires) — and confirm it by removing the identity guard and watching that test go red.

中文说明

[Critical] R2-2:[certifies-falsely] [new-surface] 这个“迟到 acquire 作废”的 release 没有句柄身份限定:conn.release() 最终走到 pool.release(id, sessionId)entry.detach(sessionId)(mcp-transport-pool.ts:558-562),拆掉的是当前占据该会话席位SessionMcpView,而不是被释放句柄自己的视图。stillWanted() 守卫触发的前提恰恰是另一条路径已经取代了本次恢复——于是这次 release 摧毁的是那个取代者。addRuntimeMcpServer 是未串行化的并发方(:3243 的 pool.acquire 没有 discoveryInFlight/pooledRecoveryInFlight 门),而本 diff 的 effectiveConfig 指纹统一(:3094-3099/:3243)让两次 acquire 落到同一个 ConnectionId——修复前 raw-config 指纹不同,二者落在不同 entry。失败链:恢复在飞(最长 30s stdio 窗口)→ 运行时重加 attach 了 view2、trackPooledConnection 删除候选 → 恢复的后续判断 stillWanted() === falseH1.release()view2.teardown() 把刚添加的 server 注册从活跃会话清除 → refs.size === 0 时 drain 定时器触发 forceShutdown,发出的是 'disconnected' 'failed'(:1775 直接忽略)——于是没有候选被记录,需求恢复永远不会重建。运行时添加路由已经带着 toolCount 返回成功,而 pooledConnections[X] 留下一个指向已杀 entry 的句柄。:1719-1722 存在同样的无身份 release。

证据:

PROBE(真实 pool+entry;同一 (name, cfg, session) 两次并发 acquire、两套 registry):
distinct handles = true | same connection id = true
after h1.release(): refs = [] | r2.removeMcpToolsByServer = 2  <- 当前席位视图被拆
entry state = draining -> closed after drainDelay | connect calls = 1
带身份限定的 release(候选修复):
refs = ['a'] | r2.removeMcpToolsByServer = 1 | entry state = active

请把 release 做成身份限定:把句柄(或每次 attach 的 token)穿入 attach 的 release 回调,席位属于其他句柄时 PoolEntry.detach 应 no-op;并把 addRuntimeMcpServer 的 acquire 与 pooledRecoveryInFlight/discoveryInFlight 串行化(discoverAllMcpToolsViaPool 已经这么做)。修复不得违反:McpTransportPool.release(id, sessionId)(:558-562)没有句柄参数,releaseSession(sessionId)(:577-593)完全无身份地批量 detach——身份化的 detach 必须保留“未提供身份”的默认路径,否则 acpAgent.killSession 会为被杀会话泄漏 ref。请补池测试:同一 (serverName, cfg, sessionId) acquire 两次(两句柄一席位),release 第一个句柄,断言 entry 仍持有该会话(refs.has(sessionId)、第二视图仍在、drain 定时器不触发);并通过移除身份守卫观察变红来验证。

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

const generation = this.pooledDiscoveryGeneration;
const exclusions = new Set(excludedNames);
this.queuedPooledDiscoveryExclusions.add(exclusions);
return this.pooledRecoveryInFlight

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-3: [fails-closed] [new-surface] A config/settings refresh that arrives while a pooled recovery is in flight chains onto pooledRecoveryInFlight with .then(onFulfilled) and NO rejection side. When the recovery rejects — any of the uncontained throws this same round reports at :1798/:1810/:1861 — the queued reconciliation NEVER RUNS: it is dropped, not deferred. discoverAllMcpToolsIncremental then rejects up into config.ts:6797-6805, whose finally also clears mcpReconcilePending, so a second settings change coalesced into the same window is discarded with it, and nothing retries until an unrelated later settings event. This is distinct from the send-path exposure: fixing only the Session.ts call site leaves this path broken, because the queued refresh chains off pooledRecoveryInFlight directly. Concretely: server X fails → the next model send starts recovery → the operator edits .mcp.json (or grants a pending-approval server) inside the recovery window → the recovery rejects → the new server's tools are never registered (or a removed server's never purged), silently, with only a [mcp-hot-reload] reconcile failed log.

Witness:

PROBE (throwing 'mcp-client-update' listener; refresh queued behind in-flight recovery):
recovery: rejected "listener boom" | queued refresh: rejected "listener boom"
pool.acquire server names: ["srv"]            <- the queued reconciliation for 'added' never ran
with the rejection side (.then(run, run), generation re-checked):
pool.acquire server names: ["srv","added"]    <- reconciliation ran
latched discoveryInFlight = false | pooledRecoveryInFlight = false | recovery block 32 passed

Give the queued chain a rejection side so the reconciliation still runs — e.g. .then(run, run) where run re-checks pooledDiscoveryGeneration before reconciling — and separately make the recovery tail non-throwing (wrap the :1861 emit).

The fix must not violate: stop() bumps pooledDiscoveryGeneration (:1912) and the if (generation !== this.pooledDiscoveryGeneration) return; check (:1546) is the only thing stopping a queued refresh from running after shutdown — the rejection side must re-check the generation too, never run the pass unconditionally.

Please add a case to the recovery suite: build the fixture with an eventEmitter whose 'mcp-client-update' listener throws, record a failure, start recoverFailedConnections unawaited, then await discoverAllMcpToolsIncremental(config) and assert it RESOLVES and pool.acquire was called for the changed server — and confirm it by removing the rejection side and watching the test go red.

中文说明

[Critical] R2-3:[fails-closed] [new-surface] 在池化恢复进行中到达的配置/设置刷新,通过 .then(onFulfilled) 挂在 pooledRecoveryInFlight 上,没有 rejection 分支。当恢复 reject——本轮在 :1798/:1810/:1861 报告的任何未收容抛出——排队中的 reconciliation 就彻底不执行:是被丢弃,不是被推迟。discoverAllMcpToolsIncremental 随之向上 reject 进 config.ts:6797-6805,其 finally 又会清掉 mcpReconcilePending,同一窗口内合并进来的第二个设置变更也一并被丢弃;在下一个不相关的设置事件之前不会有任何重试。这与发送路径的暴露是两个问题:只修 Session.ts 调用点不能修复本路径,因为排队的刷新直接挂在 pooledRecoveryInFlight 上。具体场景:server X 失败 → 下一次模型发送启动恢复 → 运维在恢复窗口内编辑 .mcp.json(或批准一个 pending-approval server)→ 恢复 reject → 新 server 的工具永远不会注册(或被移除 server 的注册永远不会清理),除一条 [mcp-hot-reload] reconcile failed 日志外毫无声响。

证据:

PROBE('mcp-client-update' 监听器抛错;刷新排在恢复之后):
recovery: rejected "listener boom" | queued refresh: rejected "listener boom"
pool.acquire server names: ["srv"]            <- 'added' 的排队 reconciliation 从未执行
带 rejection 分支(.then(run, run),两侧都复查 generation):
pool.acquire server names: ["srv","added"]    <- reconciliation 执行
latched discoveryInFlight = false | pooledRecoveryInFlight = false | recovery block 32 passed

请给排队链补上 rejection 分支让 reconciliation 仍能执行——例如 .then(run, run)run 在执行前复查 pooledDiscoveryGeneration——并单独让恢复尾部不再可抛(包裹 :1861 的 emit)。修复不得违反:stop() 会递增 pooledDiscoveryGeneration(:1912),:1546 的 generation 检查是阻止排队刷新在关停后执行的唯一屏障——rejection 分支也必须复查 generation,绝不能无条件执行。请在恢复测试组补一个用例:fixture 装配一个 'mcp-client-update' 监听器会抛错的 eventEmitter,记录失败,不 await 地启动 recoverFailedConnections,然后 await discoverAllMcpToolsIncremental(config),断言它 RESOLVE 且 pool.acquire 为变更的 server 被调用;并通过移除 rejection 分支观察变红来验证。

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

throw error;
}

if (!this.reconnectOnError && this.isConnectionError(error)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-4: [fails-closed] [regression] This branch's stated premise — "The session will restore its shared connection" — is false for MCP tools executed by in-process subagents, forks and workflow agents. recoverFailedConnections has exactly one production call site (Session.ts:7735, the ACP send path), while agent turns send through chat.sendMessageStream directly (agents/runtime/agent-core.ts:989, agents/forkedAgent.ts:552) and execute the PARENT session's projections, copied by reference into agent registries (copyDiscoveredToolsFrom, tool-registry.ts:463-476, via agent/agent.ts:531, subagent-manager.ts:1084, InProcessBackend.ts:629, workflow-orchestrator.ts:1650) — all stamped reconnectOnError=false by session-mcp-view.ts:217. When the pooled child dies mid-agent-run, the first failing call throws the do-not-retry warning and nothing re-acquires the connection: every remaining MCP call in that run fails the same way, and because session-side recovery re-applies snapshots only to the SESSION registry (SessionMcpView.applyTools), even the parent's later recovery does not refresh the copies the agent still holds. The regression is sharpest for replayable tools (trust: true + readOnlyHint/idempotentHint): at the merge base those reconnected through discoverAllMcpToolsViaPool's re-acquire and replayed successfully — the same shape with reconnectOnError=true still does at HEAD. The design doc never mentions subagents/forks, so this population was not scoped out deliberately.

Witness:

PR's own tests pin the delegation: 'leaves shared connection recovery to the session (%s copy)' -> 4 passed
(shared copies throw "Do not retry automatically"; discoverToolsForServer never called)
repo-wide grep: recoverFailedConnections production call sites = 1 (Session.ts:7735)
agent sends: agent-core.ts:989 / forkedAgent.ts:552 -> chat.sendMessageStream directly, no manager hook
same tool shape with reconnectOnError=true at HEAD: 'auto-reconnect on connection error' block -> 27 passed

Make replacement recovery reachable from every path that executes reconnectOnError=false tools — either invoke the manager's recoverFailedConnections (plus a declaration refresh) before agent-runtime model sends, or project reconnectOnError=false only for registries whose owner installs the pre-send hook (e.g. pass the flag from the session that owns the hook instead of hard-coding false in SessionMcpView.applyTools).

The fix must not violate: Session.ts:7745's once-per-turn notice dedup and :7741's setTools() pairing — a second recovery call site must not emit its own notices per agent send (that would multiply the notice the existing dedup exists to suppress) and must refresh declarations the same way.

Please add an agent-runtime test (agent-core or forkedAgent) whose registry holds a copied reconnectOnError=false MCP tool with a failed pooled connection, asserting recovery ran before the agent's send (or that the call did not surface the do-not-retry warning) — and confirm it by reverting the fix and watching the test go red, since today no agent-runtime send path touches the manager.

中文说明

[Critical] R2-4:[fails-closed] [regression] 本分支注释的前提——“会话会恢复其共享连接”——对于由进程内 subagent、fork 和 workflow agent 执行的 MCP 工具不成立。recoverFailedConnections 只有一个生产调用点(Session.ts:7735,ACP 发送路径),而 agent 轮次直接通过 chat.sendMessageStream 发送(agents/runtime/agent-core.ts:989、agents/forkedAgent.ts:552),执行的是父会话的投影工具——它们按引用复制进 agent registry(copyDiscoveredToolsFrom,tool-registry.ts:463-476,经由 agent/agent.ts:531、subagent-manager.ts:1084、InProcessBackend.ts:629、workflow-orchestrator.ts:1650),且都被 session-mcp-view.ts:217 打上 reconnectOnError=false。当池化子进程在 agent 运行中途死亡:第一个失败调用抛出 do-not-retry 警告,且没有任何路径重新 acquire——该 run 余下的所有 MCP 调用都以同样方式失败;而会话侧恢复只把快照重新应用到会话 registry(SessionMcpView.applyTools),父会话之后的恢复也不会刷新 agent 手里的副本。对可重放工具(trust: true + readOnlyHint/idempotentHint)回归最重:在 merge base 上它们会经 discoverAllMcpToolsViaPool 的重 acquire 完成重连并重放成功——HEAD 上 reconnectOnError=true 的同形工具仍然如此。设计文档从未提及 subagent/fork,说明该人群并非被有意排除。

证据:

PR 自己的测试钉住了委托行为: 'leaves shared connection recovery to the session (%s copy)' -> 4 passed
(共享副本抛 "Do not retry automatically";discoverToolsForServer 从未被调用)
全仓 grep: recoverFailedConnections 生产调用点 = 1(Session.ts:7735)
agent 发送: agent-core.ts:989 / forkedAgent.ts:552 -> 直接 chat.sendMessageStream,无 manager 钩子
HEAD 上 reconnectOnError=true 的同形工具: 'auto-reconnect on connection error' 组 -> 27 passed

请让替换恢复对每一条执行 reconnectOnError=false 工具的路径可达——要么在 agent 运行时的模型发送前调用 manager 的 recoverFailedConnections(并刷新声明),要么只对“拥有者安装了发送前钩子”的 registry 投影 reconnectOnError=false(例如由拥有钩子的会话传入该标志,而不是在 SessionMcpView.applyTools 硬编码 false)。修复不得违反:Session.ts:7745 的每轮一次提示去重与 :7741 的 setTools() 配对——第二个恢复调用点不得按 agent 发送次数发提示(会放大既有去重所要压制的重复),且必须以同样方式刷新声明。请补一个 agent-runtime 测试(agent-core 或 forkedAgent):registry 持有复制来的 reconnectOnError=false MCP 工具且池化连接已失败,断言 agent 发送前恢复已执行(或调用没有抛出 do-not-retry 警告);并通过回退修复观察变红来验证——今天没有任何 agent 运行时发送路径触及 manager。

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

Comment thread packages/core/src/tools/mcp-tool.ts Outdated
if (!this.reconnectOnError && this.isConnectionError(error)) {
// The session will restore its shared connection. The model must still
// be told that this call's outcome is unknown, to avoid a fresh retry.
throw new Error(DiscoveredMCPToolInvocation.UNSAFE_REPLAY_ERROR_MESSAGE, {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-12: This shared-connection branch reuses UNSAFE_REPLAY_ERROR_MESSAGE, whose text asserts a reason that is false for this path — "Automatic replay was skipped because the call could not be verified as safe to replay" (:317-318) — and emits it even when the call WAS verified safe: the branch fires for every connection error on a pooled tool before canSafelyReplay() is ever consulted, and the diff's own new test constructs exactly that case (trust true, isTrustedFolder()true, {readOnlyHint: true, idempotentHint: true}canSafelyReplay() true) and asserts this message (mcp-tool.test.ts:991). It is wider than the annotations, too: isConnectionError also returns true for a transport already dead BEFORE the request left (/not connected/i, /ECONNREFUSED/i, or the status gate alone), where nothing was in flight — the model is told to verify an outcome that cannot exist and not to retry a read that provably never ran, so the call is abandoned for the rest of the turn instead of being re-issued after the session restores the connection on the next send. The real reason (session-owned recovery of a shared transport) appears only in a source comment, never in the text the model or user sees.

Witness:

PROBE (two arms identical except reconnectOnError; annotations make canSafelyReplay() true):
shared(false):    message "MCP tool execution may have completed ... could not be verified as safe to replay ..." | discoverCalled = 0
standalone(true): message "Connection closed" — replay path ran | discoverCalled = 1
pre-dead transport (ECONNREFUSED), shared: "may have completed before the connection failed..." — nothing was in flight

Give the shared-connection path its own message — e.g. "The shared connection to this MCP server failed while the call was in flight. The session restores it automatically; this call was not replayed. Do not retry automatically; verify the outcome before trying again." — and, where the status gate was already DISCONNECTED before the call, a "the server was not connected; the call did not run" variant. Keep { cause: error }.

The fix must not touch the constant's other consumers: UNSAFE_REPLAY_ERROR_MESSAGE is still thrown at :470 and :500 (the genuinely-unverifiable paths) and is duplicated as a literal at mcp-tool.test.ts:1806 — change only this site, or move them together.

Please update leaves shared connection recovery to the session (%s copy) (mcp-tool.test.ts:971-995) to assert the new wording and to NOT match the "could not be verified as safe" clause for its readOnly/idempotent tool (and :1018 likewise), and confirm by restoring the constant here and watching those assertions go red.

中文说明

[Suggestion] R2-12:这个共享连接分支复用了 UNSAFE_REPLAY_ERROR_MESSAGE,其文本断言了一个对本路径不成立的理由——“Automatic replay was skipped because the call could not be verified as safe to replay”(:317-318)——并且在调用已被验证为安全时同样发出:该分支对池化工具的每个连接错误都触发,且发生在 canSafelyReplay() 被查询之前;diff 自己的新测试恰好构造了这种情形(trust trueisTrustedFolder()true{readOnlyHint: true, idempotentHint: true}canSafelyReplay() 为 true)并断言了这条消息(mcp-tool.test.ts:991)。范围还不止注解:isConnectionError请求发出前就已死亡的传输也返回 true(/not connected/i/ECONNREFUSED/i,或仅凭状态门)——此时根本没有任何在飞调用,模型却被要求去核实一个不可能存在的结果、且不得重试一个显然从未执行的读取,于是该调用在本轮余下时间被放弃,而不是等会话在下次发送恢复连接后重新发出。真实原因(会话负责恢复共享传输)只出现在源码注释里,从未出现在模型或用户可见的文本中。

证据:

PROBE(两臂除 reconnectOnError 外完全一致;注解使 canSafelyReplay() 为 true):
shared(false):    message "MCP tool execution may have completed ... could not be verified as safe to replay ..." | discoverCalled = 0
standalone(true): message "Connection closed" —— 重放路径执行 | discoverCalled = 1
传输先死(ECONNREFUSED)、shared: "may have completed before the connection failed..." —— 无在飞调用

请给共享连接路径单独的消息——例如 “The shared connection to this MCP server failed while the call was in flight. The session restores it automatically; this call was not replayed. Do not retry automatically; verify the outcome before trying again.”——并在调用前状态门已为 DISCONNECTED 的情形提供“server 未连接、调用未执行”的变体。保留 { cause: error }。修复不得影响该常量的其他消费者:UNSAFE_REPLAY_ERROR_MESSAGE 仍在 :470 与 :500(真正不可验证的路径)被抛出,并在 mcp-tool.test.ts:1806 以字面量重复——只改本调用点,或整体一起迁移。请更新 leaves shared connection recovery to the session (%s copy)(mcp-tool.test.ts:971-995),断言新措辞、并断言其 readOnly/idempotent 工具匹配 “could not be verified as safe” 从句(:1018 同理);并通过在此处恢复旧常量观察这些断言变红来验证。

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

}
expect(
(pool as unknown as { retiringEntries: Map<string, Promise<void>> })
.retiringEntries.size,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-13: This closing retiringEntries.size === 0 assertion in accounts for evicted cleanup during drain (timeout=%s) is vacuous: drainAll unconditionally runs this.retiringEntries.clear() (mcp-transport-pool.ts:885) before its single return, so the map is empty after every drain in both branches and the assertion cannot fail. It reads as the witness for barrier retention, so a maintainer who deletes the identity-checked .finally delete (:1061-1065 — the only code that retires a COMPLETED barrier outside drainAll) sees this test stay green. The missed regression: resolved cleanup promises accumulate in retiringEntries for the pool's lifetime, and each later drainAll counts those long-finished retirements through retiring.map(… retiredCount++) (:826-834), inflating the reported drained count in the daemon's shutdown log. The {drained, forced} assertions in the same test ARE meaningful; this one adds false assurance.

Witness:

PROBE (assertion repositioned BEFORE drainAll):
PRISTINE: 51 passed
MUTANT (.finally delete at :1061-1065 removed):
  shipped 'accounts for evicted cleanup during drain (timeout=false|true)' -> BOTH GREEN
  repositioned assertion -> red (expected 1 to be 0)   [1 failed | 50 passed]

Either drop the assertion, or make it non-vacuous by checking retention BEFORE the drain — after finish(), flush a microtask and assert retiringEntries.size === 0 ahead of pool.drainAll(...), so it pins the .finally delete rather than drainAll's clear.

The repositioned assertion must go red in both timeout=false and timeout=true cases when the .finally delete at mcp-transport-pool.ts:1061-1065 is removed; the current post-drain placement stays green under the same mutation — please confirm both directions.

中文说明

[Suggestion] R2-13:accounts for evicted cleanup during drain (timeout=%s) 末尾这条 retiringEntries.size === 0 断言是空断言:drainAll 在其唯一 return 之前无条件执行 this.retiringEntries.clear()(mcp-transport-pool.ts:885),因此两个分支下 drain 之后映射必为空,断言不可能失败。它读起来像是 barrier 保留的证据,于是删除带身份检查的 .finally delete(:1061-1065——drainAll 之外唯一退休已完成 barrier 的代码)的维护者会看到该测试保持绿色。被漏掉的回归:已 resolve 的 cleanup promise 会在池的整个生命周期内累积在 retiringEntries 中,之后每次 drainAll 都会通过 retiring.map(… retiredCount++)(:826-834)把这些早已结束的退休计入,虚增 daemon 关停日志里的 drained 计数。同一测试中的 {drained, forced} 断言有效的;这一条只增加虚假保证。

证据:

PROBE(把断言移到 drainAll 之前):
PRISTINE: 51 passed
MUTANT(移除 :1061-1065 的 .finally delete):
  已发布的 'accounts for evicted cleanup during drain (timeout=false|true)' -> 双双保持绿
  重定位后的断言 -> 红(expected 1 to be 0)   [1 failed | 50 passed]

请删除该断言,或把它改为非空断言:在 drain 之前检查保留——finish() 之后冲刷一个 microtask、在 pool.drainAll(...) 之前断言 retiringEntries.size === 0——使其钉住 .finally delete 而非 drainAll 的 clear。重定位后的断言必须在移除 mcp-transport-pool.ts:1061-1065 的 .finally delete 时于 timeout=falsetimeout=true 两个分支都变红;当前的 drain 后位置在同一变异下保持绿色——请双向确认。

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

let completedCleanup: Promise<void> | undefined;
while (
(cleanup =
this.retiringEntries.get(id) ??

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-14: The retiringEntries operand of the new acquire barrier — the ONLY one that can see a retirement in the production demand-recovery sequence — has no test witness. In the silent-drop path the entry publishes cleanupInFlight (mcp-pool-entry.ts:416), emits 'failed', then calls onClosed(this.id) synchronously (:509), which evicts it from entries and moves the barrier into retiringEntries for the remaining sweep (several seconds: bounded pid queries + the SDK stdio close's 2s races). Demand recovery acquires on the NEXT model send — post-eviction — so entries.get(id)?.waitForCleanup() is undefined and retiringEntries.get(id) is the sole barrier. Deleting this operand keeps all 50 pool tests green (mutation run), because both waits for old cleanup before %s starts a replacement cases start their acquire INSIDE the 'failed' handler (test :396-407) — pre-eviction — and so pin only the entries operand. Production consequence of the deletion: a replacement child spawns CONCURRENTLY with the dying child's SIGTERM sweep — two live children for one connection id — and where the dying child holds an exclusive resource (local socket, lock file, debug port) the fresh connect() fails, arming the 5s cooldown and reporting "remains disconnected" until the old child exits.

Witness:

PROBE (silent drop -> eviction settled: retiringEntries.size=1, entries.size=0 -> acquireForRecovery parked on unresolved client.disconnect):
PRISTINE: connect stays 1 while cleanup open; 2 after release
MUTANT (`this.retiringEntries.get(id) ??` deleted): connect = 2 while cleanup still open — 1 failed | 50 passed (probe red, all shipped tests green)

Add a case to describe('demand recovery') that lets the silent-drop listener run to completion first — trigger onerror, then await vi.advanceTimersByTimeAsync(0) so onClosed has evicted the entry and retiringEntries.size === 1 — with client.disconnect mocked unresolved, and only THEN calls pool.acquireForRecovery(...): assert mocked.connect is still toHaveBeenCalledTimes(1) before the mock is released and 2 after.

The acquire must start AFTER the eviction, not inside the 'failed' handler: mcp-pool-entry.ts:509 runs synchronously after the emit at :487, which is exactly why the existing :396-407 shape exercises the other operand.

The new case must go red when this.retiringEntries.get(id) ?? is deleted from the acquire loop — no existing test does (verified: 50/50 green under that mutation).

中文说明

[Suggestion] R2-14:新 acquire barrier 的 retiringEntries 操作数——在生产的需求恢复时序中唯一能看到退休状态的操作数——没有测试证据。silent-drop 路径中,entry 先发布 cleanupInFlight(mcp-pool-entry.ts:416)、发出 'failed',随后同步调用 onClosed(this.id)(:509),把它从 entries 逐出、并把 barrier 移入 retiringEntries 以覆盖余下的清扫(数秒级:有界的 pid 查询 + SDK stdio close 的两个 2s 竞速)。需求恢复在下一次模型发送时才 acquire——即逐出之后——此时 entries.get(id)?.waitForCleanup()undefinedretiringEntries.get(id) 是唯一 barrier。删除该操作数后全部 50 个池测试仍绿(变异实测),因为两个 waits for old cleanup before %s starts a replacement 用例都在 'failed' handler 内部发起 acquire(test :396-407)——逐出之前——只钉住了 entries 操作数。删除后的生产后果:替代子进程会与垂死子进程的 SIGTERM 清扫并发启动——同一 connection id 出现两个存活子进程——当垂死子进程持有排他资源(本地 socket、锁文件、调试端口)时,新的 connect() 失败、5s 冷却被触发,并在旧子进程退出前一直报告 “remains disconnected”。

证据:

PROBE(silent drop -> 逐出完成: retiringEntries.size=1, entries.size=0 -> acquireForRecovery 停在未 resolve 的 client.disconnect 上):
PRISTINE: cleanup 未结束时 connect 保持 1;释放后为 2
MUTANT(删除 `this.retiringEntries.get(id) ??`): cleanup 仍未结束时 connect = 2 —— 1 failed | 50 passed(探针红、全部已发布测试绿)

请在 describe('demand recovery') 补一个用例:先让 silent-drop 监听器完整执行——触发 onerror,然后 await vi.advanceTimersByTimeAsync(0) 使 onClosed 完成逐出且 retiringEntries.size === 1——client.disconnect mock 为不 resolve,之后才调用 pool.acquireForRecovery(...):断言释放 mock 前 mocked.connect 仍为 toHaveBeenCalledTimes(1)、释放后为 2。acquire 必须在逐出之后发起,而不是在 'failed' handler 内:mcp-pool-entry.ts:509 在 :487 的 emit 之后同步执行——这正是既有 :396-407 形态只覆盖另一操作数的原因。新用例必须在从 acquire 循环删除 this.retiringEntries.get(id) ?? 时变红——现有测试都不会(已验证:该变异下 50/50 绿)。

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

) {
await cleanup;
completedCleanup = cleanup;
if (this.draining) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-15: No test starts a drain while an acquire is parked on the new cleanup barrier, so this mid-loop draining refusal has no witness: deleting the check keeps all 50 pool tests green (mutation run). The new drain tests either force-close with no concurrent acquire, or drain after the entry is already evicted; none has an acquire/acquireForRecovery awaiting waitForCleanup() when drainAll() sets this.draining = true. In production, a model send that requests recovery concurrently with daemon shutdown would, once the old child's cleanup resolves, fall through to spawnEntry — a stdio child spawned during shutdown that the pool then discards at :1128-1137, costing a spawn plus up to the 30s stdio discovery wait inside the shutdown deadline.

Witness:

MUTATION (mid-loop draining check deleted): vitest mcp-transport-pool.test.ts -> 50 passed — no witness
PROBE (silent drop -> acquireForRecovery parked on the barrier -> drainAll -> cleanup released):
PRISTINE: parked acquire -> REJECTED "McpTransportPool is draining; refusing acquire for srv" | extra connect calls = 0
MUTATED:  parked acquire -> REJECTED "McpTransportPool is draining; discarded spawn srv::..." | extra connect calls = 1

Add a pool test: acquire an entry, make its client.disconnect hang, trigger the silent-drop path so cleanup is in flight, start pool.acquireForRecovery (parked on the barrier), call pool.drainAll(), then release cleanup and assert the acquire rejects with the refusing acquire message and connect was never called a second time.

The witness must assert rejection BEFORE spawning (connect call count), not merely that no entry survives: the discarded-spawn throw at :1137 runs only after client.connect() + discoverAndReturn under runWithTimeout(…, discoveryTimeoutFor(cfg)).

That new test must go red when the if (this.draining) throw block is deleted from the cleanup-wait loop — please confirm with that mutation.

中文说明

[Suggestion] R2-15:没有任何测试在 acquire 停在新 cleanup barrier 上时启动 drain,因此这个循环中段的 draining 拒绝没有证据:删除该检查后全部 50 个池测试仍绿(变异实测)。新的 drain 测试要么在无并发 acquire 时 force-close,要么在 entry 已被逐出后才 drain;没有一个用例让 acquire/acquireForRecoverydrainAll() 置位 this.draining = true 时正 await waitForCleanup()。生产环境中,与 daemon 关停并发请求恢复的模型发送,会在旧子进程 cleanup resolve 之后落入 spawnEntry——在关停期间启动一个 stdio 子进程,随后在 :1128-1137 被丢弃,白白付出一次 spawn 加最长 30s 的 stdio 发现等待,且发生在关停截止时间之内。

证据:

MUTATION(删除循环中段 draining 检查): vitest mcp-transport-pool.test.ts -> 50 passed —— 无证据
PROBE(silent drop -> acquireForRecovery 停在 barrier -> drainAll -> 释放 cleanup):
PRISTINE: parked acquire -> REJECTED "McpTransportPool is draining; refusing acquire for srv" | extra connect calls = 0
MUTATED:  parked acquire -> REJECTED "McpTransportPool is draining; discarded spawn srv::..." | extra connect calls = 1

请补池测试:acquire 一个 entry,让其 client.disconnect 挂起,触发 silent-drop 路径使 cleanup 在飞,启动 pool.acquireForRecovery(停在 barrier 上),调用 pool.drainAll(),随后释放 cleanup,断言 acquire 以 refusing acquire 消息拒绝、且 connect 从未第二次被调用。证据必须断言在 spawn 之前拒绝(connect 调用计数),而不只是没有存活 entry::1137 的 discarded-spawn 抛出发生在 client.connect() + discoverAndReturnrunWithTimeout(…, discoveryTimeoutFor(cfg)) 之下)之后。删除 cleanup-wait 循环中的 if (this.draining) throw 块时该新测试必须变红——请用该变异确认。

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

if (signal.aborted || this.failedPooledConnections.size === 0) return;
const servers = this.getEffectiveMcpServers();
const budget = this.pool!.getBudget();
budget?.beginBulkPass();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-40: (fix-induced) The bulk-pass bracket added for R1-40 wraps EVERY model-send recovery in the workspace budget, and beginBulkPass's documented side effect wipes lastRefusedServerNames (mcp-workspace-budget.ts:233-238: "on the OUTERMOST beginBulkPass (depth 0 → 1), this resets lastRefusedServerNames") — the field GET /workspace/mcp reads as refusedByBudget (acpAgent.ts:6526-6532 → :6592, same budget instance via acpAgent.ts:4556-4574) — even though a recovery pass never re-evaluates budget-refused servers (a refused server never held a connection, so it can never be a failedPooledConnections candidate). The recovered server re-acquires through the fast path (no tryReserve → no recordRefusal), and endBulkPass's flushRefusedBatch early-returns on empty pending (:265-268), so nothing is restored and no batch is re-published. A second face, same root: flushRefusedBatch ASSIGNS rather than unions (:286), so a recovery whose OWN acquire is refused narrows the workspace-wide set to that single name. Either way the delivered mcp_child_refused_batch and the snapshot permanently disagree — with no config change, no re-evaluation, and no event — until some later full discovery re-refuses; an operator UI or SDK consumer (packages/sdk-typescript/src/daemon/events.ts:506-515 tells consumers to reconcile against refusedServerNames) prompting "raise the MCP budget" silently stops prompting. This fires on the ordinary successful-recovery path.

Witness:

PROBE (real WorkspaceMcpBudget, enforce, budget 2; refusals seeded for C,D after discovery):
successful recovery of a DIFFERENT server: refused_BEFORE ["C","D"] -> refused_AFTER [] | refused_batch events delta = 0
refused-recovery arm: refused_AFTER ["srv"]   <- C and D dropped (assign, not union)
MUTATED (recovery's beginBulkPass line removed): refused_AFTER ["C","D"] on both arms

Keep the bracket (recovery's own refusals must still coalesce) but stop a partial pass from erasing another pass's record: capture budget.getRefusedServerNames() before beginBulkPass() and MERGE-restore it after endBulkPass() when the recovery recorded no refusals of its own — merge, not conditional restore, because the assign face shows a partial pass must union — or add a non-resetting beginPartialPass() scope used only by recovery.

The fix must not violate the discovery contract: the outermost-begin reset (mcp-workspace-budget.ts:234-236) is pinned for discovery by mcp-client-manager.test.ts:2824 ('discoverAllMcpTools resets lastRefusedServerNames each pass') and design §4 requires budget refusals to retain the workspace refusal batch — scope the change to recovery; do not delete the reset.

Please add a recovery-suite test with a real WorkspaceMcpBudget in enforce mode: seed a refusal for an unrelated server, run a successful recoverFailedConnections for a different failed server, and assert getRefusedServerNames() still contains the unrelated name — plus the coalescing half (a recovery whose acquire IS refused still emits exactly one refused_batch); confirm by reverting to the bare bracket and watching the first assertion go red.

中文说明

[Suggestion] R1-40:(修复引入)为回应 R1-40 而加的 bulk-pass 括号把每次模型发送的恢复都包进工作区预算,而 beginBulkPass 的文档化副作用会清空 lastRefusedServerNames(mcp-workspace-budget.ts:233-238:“最外层 beginBulkPass(depth 0 → 1)会重置 lastRefusedServerNames”)——这正是 GET /workspace/mcp 读取为 refusedByBudget 的字段(acpAgent.ts:6526-6532 → :6592,经 acpAgent.ts:4556-4574 为同一预算实例)——尽管恢复 pass 从不重新评估被预算拒绝的 server(被拒绝的 server 从未持有连接,永远不可能成为 failedPooledConnections 候选)。被恢复的 server 走快路径重 acquire(无 tryReserve → 无 recordRefusal),endBulkPassflushRefusedBatch 在 pending 为空时提前返回(:265-268),于是没有任何恢复、也不会重新发布 batch。同根的第二面:flushRefusedBatch赋值而非并集(:286),所以当恢复自身的 acquire 被拒绝时,工作区级集合会被收窄为这一个名字。两种情形下,已投递的 mcp_child_refused_batch 与快照都会永久不一致——没有配置变更、没有重评估、没有事件——直到某次完整发现重新拒绝为止;提示“提高 MCP 预算”的运维 UI 或 SDK 消费者(packages/sdk-typescript/src/daemon/events.ts:506-515 要求消费者用 refusedServerNames 对账)会静默停止提示。这在普通的恢复成功路径上就会发生。

证据:

PROBE(真实 WorkspaceMcpBudget,enforce,预算 2;发现后为 C、D 播种拒绝):
恢复另一个 server 成功: refused_BEFORE ["C","D"] -> refused_AFTER [] | refused_batch 事件增量 = 0
恢复自身被拒绝的分支: refused_AFTER ["srv"]   <- C、D 被丢弃(赋值而非并集)
MUTATED(移除恢复中的 beginBulkPass 行): 两个分支 refused_AFTER 均为 ["C","D"]

请保留括号(恢复自身的拒绝仍需合并),但阻止一个局部 pass 抹掉另一个 pass 的记录:在 beginBulkPass() 前捕获 budget.getRefusedServerNames(),并在 endBulkPass() 后、当恢复没有记录自己的拒绝时合并恢复——必须合并而非条件还原,因为赋值面表明局部 pass 需要并集——或者增加仅供恢复使用的非重置 beginPartialPass() 作用域。修复不得违反发现流程的契约:最外层 begin 的重置(mcp-workspace-budget.ts:234-236)由 mcp-client-manager.test.ts:2824('discoverAllMcpTools resets lastRefusedServerNames each pass')钉住,且设计 §4 要求预算拒绝保留工作区拒绝 batch——请把改动限定在恢复路径,不要删除该重置。请在恢复测试组补一个使用真实 WorkspaceMcpBudget(enforce 模式)的测试:为一个无关键 server 播种拒绝,对另一个失败 server 执行成功的 recoverFailedConnections,断言 getRefusedServerNames() 仍包含该无关键名——外加合并半边(acquire 被拒绝的恢复仍恰好发出一个 refused_batch);并通过回退为裸括号观察第一个断言变红来验证。

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

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

4 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R2-7 per-turn notice dedup keyed on the notice text (Session.ts:7750) — still stands; already reported (comment 3966612906)
  • R2-8 every recovery test in the block fails exactly one server, so batch semantics are untested (mcp-client-manager.test.ts:4814) — still stands; already reported (comment 3966612913)
  • R2-9 none of the three staleness-sweep arms observes the candidate delete (mcp-client-manager.test.ts:5063) — still stands; already reported (comment 3966612919)
  • R2-13 the closing retiringEntries.size === 0 assertion is vacuous because drainAll clears the map first (mcp-transport-pool.test.ts:677) — still stands; already reported (comment 3966612944). This round independently reproduced the vacuity …

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

Not reviewed: build-and-test — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) were skipped in CI, and this diff changes process-teardown code (descendant-pid enumeration, SIGTERM then SIGKILL) whose behaviour is platform-specific; only the linux Test job ran.

Not reviewed: build-and-test — the test-efficacy probe produced no measurement: all 12 revert probes returned inconclusive and harnessValidated is null because the positive control never ran (its runner exited 1 on the vitest globalSetup prerequisite guard), so mutation and hunk-necessity coverage is unmeasured rather than clean.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": enumerating which of the ~40 getToolRegistry: vi.fn().mockReturnValue({…}) mocks in packages/cli/src/acp-integration/acpAgent.test.ts actually reach Sessio…; "agent reverse-audit (round 1)": did not execute packages/core/src/tools/tool-registry.mcp-recovery.test.ts (or session-mcp-view.test.ts ) under vitest to confirm the new suites are green in…; "agent reverse-audit (round 2)": the budget layer — I did not read WorkspaceMcpBudget.tryReserve / beginBulkPass / endBulkPass or evictEntry 's budget release, so I could not confirm whether…; "agent reverse-audit (round 2)": I did not execute packages/core/src/tools/mcp-client.test.ts or mcp-client-manager.test.ts ; the new-test analysis above is a hand trace of the mocks against…; "agent reverse-audit (round 2)": none of my scope was cut short, but two checks I would have liked were left unrun because they sit outside chunk 12 and are owned elsewhere: whether Config.shu….

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

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

  • docs/users/features/mcp.md:310 — [probe] The user doc states identical diagnostics appear 'once per user turn', but the implementation suppresses only CONSECUTIVE identical messages per server, so the same text can appear twice in one turn
  • docs/users/features/mcp.md:314 — [review] 'untrusted ... servers are not started by this recovery path' misstates the gate - recovery is gated on WORKSPACE/FOLDER trust, not on the server-level trust:true flag that this same file's precedin…
  • packages/core/src/tools/mcp-client-manager.ts:1771 — [probe] Extracting the stopTimedOut gate into trackPooledConnection re-staled the two line citations in stop() that point at it, and dropped the rationale comments for both invariants it …
  • packages/core/src/tools/mcp-client-manager.ts:1787 — [probe] trackPooledConnection's onFailed now emits mcp-client-update and no test asserts that emit - the interactive CLI's stale-CONNECTED refresh depends on it
  • packages/core/src/tools/mcp-client-manager.ts:2756 — [probe] Both new cleanup lines in removeServer are unreachable with a non-empty map: removeServer only runs in non-pool mode, while failedPooledConnections/recoveryNotices are only ever p…
  • packages/core/src/tools/mcp-client.ts:553 — [probe] The new constructor onclose wrapper becomes a second population site for lastTransportError, falsifying both doc contracts that define the field as carrying only a real upstream onerror ca…
  • packages/core/src/tools/mcp-pool-entry.ts:410 — [probe] The relocated comment still declares the cleanup chain fire-and-forget ( void ), but this diff replaced the void with an assignment to cleanupInFlight that the pool awaits as a teard…

Convergence: round 3 posted 35 inline comment(s), 33 of them reported for the first time; the previous round posted 19 (19 new). Findings keep coming back to the same files: packages/core/src/tools/mcp-client-manager.ts (findings in rounds 1, 2; 10 more now); packages/core/src/tools/mcp-client-manager.test.ts (findings in round 2; 4 more now); packages/core/src/tools/mcp-transport-pool.ts (findings in round 2; 4 more now), and 5 more file(s). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

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

本轮确认的 4 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

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

未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) were skipped in CI, and this diff changes process-teardown code (descendant-pid enumeration, SIGTERM then SIGKILL) whose behaviour is platform-specific; only the linux Test job ran.

未审查(原文为英文):build-and-test — the test-efficacy probe produced no measurement: all 12 revert probes returned inconclusive and harnessValidated is null because the positive control never ran (its runner exited 1 on the vitest globalSetup prerequisite guard), so mutation and hunk-necessity coverage is unmeasured rather than clean.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"enumerating which of the ~40 getToolRegistry: vi.fn().mockReturnValue({…}) mocks in packages/cli/src/acp-integration/acpAgent.test.ts actually reach Sessio…"agent reverse-audit (round 1)"did not execute packages/core/src/tools/tool-registry.mcp-recovery.test.ts (or session-mcp-view.test.ts ) under vitest to confirm the new suites are green in…"agent reverse-audit (round 2)"the budget layer — I did not read WorkspaceMcpBudget.tryReserve / beginBulkPass / endBulkPass or evictEntry 's budget release, so I could not confirm whether…"agent reverse-audit (round 2)"I did not execute packages/core/src/tools/mcp-client.test.ts or mcp-client-manager.test.ts ; the new-test analysis above is a hand trace of the mocks against…"agent reverse-audit (round 2)"none of my scope was cut short, but two checks I would have liked were left unrun because they sit outside chunk 12 and are owned elsewhere: whether Config.shu…

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

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

收敛情况:第 3 轮发布了 35 条行内评论,其中 33 条是首次提出;上一轮发布了 19 条(其中 19 条首次提出)。发现反复回到同一批文件:packages/core/src/tools/mcp-client-manager.ts(第 1、2 轮已出过发现,本轮又有 10 条);packages/core/src/tools/mcp-client-manager.test.ts(第 2 轮已出过发现,本轮又有 4 条);packages/core/src/tools/mcp-transport-pool.ts(第 2 轮已出过发现,本轮又有 4 条),另有 5 个文件。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)

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

Comment on lines +2066 to +2068
for (const exclusions of this.queuedPooledDiscoveryExclusions) {
exclusions.add(serverName);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R3-3: [certifies-falsely] [new-surface] The queued-discovery exclusion set is add-only, and that makes a later pass destructive rather than merely narrower. disconnectServer writes a name into every registered exclusion set here, and nothing anywhere removes it; the reconcile closure copies the set forward on each re-queue. So if an operator disconnects a server and then immediately re-adds it with a corrected config, the stale exclusion survives into the reconciled pass that runs after the add already succeeded. That pass omits the name from desiredIds, which means the release loop at :1650-1663 sees desired === undefined for a live, tracked connection and calls conn.release() plus pooledConnections.delete(name). The API has just returned success with a populated toolCount, and the session's only pool reference for that server is then torn down: the refcount hits zero, the drain timer force-closes it, and the server's tools fail on the next call. Nothing re-acquires it, because addRuntimeMcpServerInner ends at trackPooledConnection and triggers no follow-up pass.

Witness:

[probe, two arms in one run, built McpClientManager]
AS-IS:
  exclusion sets after disconnectServer: [["srv"]]
  exclusion sets after recovery settled: [["srv"],["srv"]]   <- copied forward one hop
  addResult: {"name":"srv","transport":"stdio","replaced":false,"toolCount":1,...}
  readded.release call count = 1
  pooledConnections.has('srv') = false
WITH the one-line fix below:
  readded.release call count = 0
  pooledConnections.has('srv') = true
// Give the exclusion a removal side symmetric to disconnectServer's add, in
// addRuntimeMcpServer (next to runtimeMcpAddTokens.set(name, token)) and on any
// path that re-establishes a server:
for (const exclusions of this.queuedPooledDiscoveryExclusions) {
  exclusions.delete(name);
}
// Alternatively make it self-expiring: store { name, transportId } and skip only
// while connectionIdOf(name, config) still equals the id that was disconnected.

The fix must not undo the disconnect-supersede behaviour that mcp-client-manager.test.ts:5570 pins (expect(other.release).toHaveBeenCalledTimes(1)): a server disconnected while its own acquire is still in flight must still hit the excludedNames.has(name) release and be released exactly once.

Please add a case that fails srv, holds a recovery open, starts a refresh so an exclusion set is registered, disconnects srv, then re-adds it and lets both settle, asserting the newly acquired connection's release was not called and pooledConnections still holds srv after the queued reconciliation; it goes red with the add-only exclusion in place.

中文说明

[Critical] R3-3:[certifies-falsely] [new-surface] 排队发现的排除集合只增不减,这会让后续的一轮发现变成破坏性的,而不仅仅是范围变窄。disconnectServer 在此处把服务名写入每一个已注册的排除集合,而代码中没有任何地方把它移除;reconcile 闭包在每次重新排队时都会把集合复制一份继续传递。因此当运维先断开某个服务、随后立刻用修正后的配置重新添加它时,这条过期的排除记录会存活到“添加已成功之后”才执行的那一轮 reconcile 中。该轮会把这个名字从 desiredIds 中省略,于是 :1650-1663 的释放循环对一个仍然存活且已被跟踪的连接看到 desired === undefined,进而调用 conn.release()pooledConnections.delete(name)。此时 API 刚刚返回成功并带上了有效的 toolCount,而该服务在这个会话中唯一的池引用却被拆掉了:引用计数归零、drain 定时器强制关闭它,服务的工具在下一次调用时失败。没有任何逻辑会重新获取它,因为 addRuntimeMcpServerInnertrackPooledConnection 处结束,不会再触发后续发现。

证据见上方 probe 输出(同一次运行内两个分支):现状下 readded.release 调用次数为 1 且 pooledConnections.has('srv') 为 false;套用下方一行修复后分别为 0 和 true。

修复方式见上方代码块:给排除状态补上与 disconnectServer 添加操作对称的移除侧(在 addRuntimeMcpServer 中、以及任何重新建立服务的路径上执行 exclusions.delete(name)),或者改为自带过期语义(存 { name, transportId },仅当 connectionIdOf(name, config) 仍等于被断开时的 id 才跳过)。

修复不得破坏 mcp-client-manager.test.ts:5570 所钉住的“断开优先于排队刷新”行为(expect(other.release).toHaveBeenCalledTimes(1)):一个在自身 acquire 仍在进行时被断开的服务,仍必须命中 excludedNames.has(name) 的释放分支且只释放一次。

请补一个用例:让 srv 失败、保持一次恢复在飞、发起一次刷新以注册排除集合、断开 srv、再重新添加它并让两者都结束,断言新获取连接的 release 没有被调用、且排队 reconcile 完成后 pooledConnections 仍持有 srv;在只增不减的现状下该用例会变红。

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

Comment on lines 1556 to 1558
if (this.discoveryInFlight) return this.discoveryInFlight;
this.discoveryInFlight = this.runDiscoverAllMcpToolsViaPool(
cliConfig,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R3-4: [fails-closed] [new-surface] This re-entrancy guard now hands a caller the promise of a pass that is narrower than what it asked for. A pass started by a queued reconcile carries a live excludedNames set, and a plain pass sets neither pooledRecoveryInFlight nor runtimeMcpAddInFlight, so pendingMutation is undefined and an unrestricted discoverAllMcpTools* caller falls straight through to this bare guard and coalesces onto it. Its await then resolves successfully having discovered nothing for the excluded server, and it schedules no follow-up pass. Concretely: a server is down, a refresh queues behind the recovery, the operator disconnects that server, the reconciled pass starts with excludedNames = {srv} and sits in await Promise.all(acquirePromises) for the other servers (a seconds-wide window, bounded by the 30s stdio / 5s remote discovery timeouts). A .mcp.json hot-reload that just re-added the server, a trust grant, or the next round's refresh arrives during that window, receives the narrowed pass, and returns green. The server stays unconnected and unregistered until some unrelated event triggers a fresh pass. Before this diff the handoff was sound, because every pass was full-scope.

Witness:

[probe]
 narrowed pass P in flight = true | acquire so far = ["srv","other","third"]
 discoveryInFlight unchanged by B (B coalesced, started no pass of its own) = true
 B resolved = true | acquire names after B = ["srv","other","third"]
 srv acquired by/after B = false
 pooledConnections keys = ["other","third"] | srv configured/trusted/enabled = true
 after a later unrestricted pass C: acquire names = [...,"srv"] | keys = ["other","third","srv"]

The last line is the discriminator: the same unrestricted call one moment later does acquire srv, so the server was acquirable and B's failure to acquire it is caused by coalescing onto the narrowed pass.

// Record the exclusion set of the in-flight pass and only coalesce when it
// covers the caller's request; otherwise queue behind it as pendingMutation does.
private discoveryInFlightExclusions?: ReadonlySet<string>;

if (
  this.discoveryInFlight &&
  [...excludedNames].every((n) => this.discoveryInFlightExclusions!.has(n))
) {
  return this.discoveryInFlight;
}

The extra pass this queues must not re-acquire and re-release the sibling server that mcp-client-manager.test.ts:5570 pins at exactly one release.

Please add a case beside releases a late acquire when disconnect supersedes a queued refresh (:5537): fail srv, start a recovery with a never-resolving acquireForRecovery, start a refresh so it queues, disconnect srv, let the reconciled pass start with pool.acquire pending for a sibling, re-add srv, then call discoverAllMcpTools again and assert pool.acquire was called with srv before that second call resolves. Without the fix the second call resolves on the exclusion-scoped pass and acquire is never called for srv.

One consequence worth naming: an excluded server lands in neither pooledConnections nor failedPooledConnections, so the demand-recovery mechanism this PR adds cannot restore it either — only a fresh unrestricted discovery pass can.

中文说明

[Critical] R3-4:[fails-closed] [new-surface] 这个重入保护现在会把一个“范围比调用方请求更窄”的 pass 的 promise 交给调用方。由排队 reconcile 启动的 pass 携带着一个生效中的 excludedNames 集合,而普通 pass 既不会设置 pooledRecoveryInFlight 也不会设置 runtimeMcpAddInFlight,因此 pendingMutation 为 undefined,一个不受限的 discoverAllMcpTools* 调用方会直接落到这个裸保护上并与之合并。它的 await 随后成功返回,但对被排除的服务什么都没发现,而且不会再安排后续 pass。具体场景:某服务已下线,一次刷新排队等待恢复;运维断开该服务;reconcile 出的 pass 以 excludedNames = {srv} 启动,并停在针对其它服务的 await Promise.all(acquirePromises) 上(窗口有数秒宽,受 30s stdio / 5s 远程发现超时约束)。此时刚好重新添加了该服务的 .mcp.json 热重载、一次信任授予、或下一轮刷新到来,拿到这个被收窄的 pass 并“绿色”返回。该服务会一直保持未连接、未注册,直到某个无关事件触发新一轮发现。在本次改动之前这个交接是可靠的,因为每一轮 pass 都是全量的。

证据见上方 probe:srv acquired by/after B = false,而稍后一次不受限的 pass C 确实获取了 srv —— 这一行是判别依据,说明该服务本可获取,B 没获取到是因为合并到了被收窄的 pass 上。

修复方式见上方代码块:记录在飞 pass 的排除集合,仅当它覆盖调用方请求时才合并,否则像 pendingMutation 分支一样排到它后面。

这次额外排队的 pass 不得重新获取并重新释放 mcp-client-manager.test.ts:5570 钉住为“恰好释放一次”的兄弟服务。

请在 releases a late acquire when disconnect supersedes a queued refresh(:5537)旁边补一个用例:让 srv 失败、以一个永不 resolve 的 acquireForRecovery 启动恢复、发起一次刷新使其排队、断开 srv、让 reconcile 出的 pass 在某个兄弟服务的 pool.acquire 挂起时开始、重新添加 srv,然后再次调用 discoverAllMcpTools 并断言在第二次调用 resolve 之前 pool.acquire 已针对 srv 被调用。没有修复时,第二次调用会在带排除范围的 pass 上 resolve,srvacquire 永远不会发生。

另有一点值得指出:被排除的服务既不在 pooledConnections 也不在 failedPooledConnections 中,因此本 PR 新增的按需恢复机制同样无法修复它 —— 只有一次全新的、不受限的发现流程才行。

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

Comment on lines +2847 to +2849
pooled.release();
this.pooledConnections.delete(serverName);
this.failedPooledConnections.set(serverName, {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-12: [fails-closed] [regression] Still standing. This self-heal treats any non-CONNECTED status as a dead handle, which includes the transient window of an intentional PoolEntry.restart(). doRestartInner begins by cancelling the drain timer, clearing maxIdleTimer and moving 'draining' back to 'active', then awaits sweepAndDisconnect('restart') and the bounded connect-plus-discover. Throughout that span (up to the 30s stdio bound) the shared client reports DISCONNECTED then CONNECTING while the entry stays state: 'active' and the manager keeps its handle, because the restartInProgress path never emits 'failed' and so onFailed does not fire. A resource read landing in that window passes the new eligibility gate (a restart changes neither fingerprint, trust nor approval), reaches getStatus() !== CONNECTED, and calls pooled.release(). That reaches McpTransportPool.release, which at refs.size === 0 calls startDrainTimer — and startDrainTimer unconditionally writes state = 'draining', re-arming the very timer the restart just cancelled. Two wrong outcomes follow: a healthy restarted transport is left draining with zero refs and is force-shut-down 30s later unless a turn boundary happens to re-acquire in time; or, if the drain timer fires during the restart's awaits, forceShutdown closes the entry and onClosed evicts it from pool.entries, after which doRestartInner resumes into client.connect() and spawns a subprocess the pool no longer tracks — precisely the orphan the comment at mcp-pool-entry.ts:960-975 was written to prevent. The same branch also records a failure, so the next turn's recovery calls acquireForRecovery against a still-CONNECTING entry and arms a 5s cooldown on a server that was never broken. Pre-diff this branch only deleted the handle and threw.

Witness:

[probe — manager layer driven directly; PoolEntry/McpTransportPool layer read-verified]
 tracked after discovery: true | release calls: 0
 readResource threw: MCP server 'srv' pool entry disconnected; retry after discovery.
 handle.release call count = 1
 pooledConnections.has(srv) = false
 failedPooledConnections = [["srv","srv::efe00b0f960db472"]]
 acquireForRecovery calls = 1 | recordRecoveryFailure calls = [["srv::efe00b0f960db472"]]
 recovery notice = "MCP server 'srv' remains disconnected. Recovery failed or is cooling down..."

The diff's - side shows the pre-change branch did only pooledConnections.delete(serverName); throw.

// Do not treat a restarting entry as failed. Ask the pool whether the entry
// behind the handle is mid-restart (expose restartInProgress on PooledConnection,
// or add a pool-owned reportStaleHandle(handle) that no-ops for non-terminal
// restarting entries) and in that case throw the existing error WITHOUT
// pooled.release() and without touching failedPooledConnections.
// Failing that, gate the release on the entry being in a terminal pool state
// ('failed' / 'closed') rather than on client.getStatus() alone.

Any fix must keep the seat attached (or otherwise prevent refs.size === 0) for the duration of a restart, because startDrainTimer (mcp-pool-entry.ts:711-712) unconditionally overwrites the 'active' state that doRestartInner established at :978, and McpTransportPool.release reaches it via mcp-transport-pool.ts:590-595.

Please add a case installing a pooled handle whose client.getStatus() returns CONNECTING while the pool reports the entry as restarting, then assert (a) handle.release was not called, (b) failedPooledConnections holds no entry for that server, and (c) the call still rejects with pool entry disconnected. Removing the restart guard must turn (a) and (b) red.

中文说明

[Critical] R1-12:[fails-closed] [regression] 该问题仍然存在。这个自愈分支把任何CONNECTED 状态都当成句柄已死,其中就包括一次有意的 PoolEntry.restart() 所处的短暂窗口。doRestartInner 开头会取消 drain 定时器、清空 maxIdleTimer 并把 'draining' 切回 'active',随后 await sweepAndDisconnect('restart') 以及带超时的连接与发现流程。在这整段时间里(最长可达 stdio 的 30s 上限),共享 client 的状态先是 DISCONNECTED 再是 CONNECTING,而 entry 仍保持 state: 'active'、manager 仍持有句柄 —— 因为 restartInProgress 路径不会发出 'failed' 事件,onFailed 也就不会触发。落在这个窗口里的资源读取会通过新的资格校验(重启不改变指纹、信任与审批状态),走到 getStatus() !== CONNECTED,于是调用 pooled.release()。它会到达 McpTransportPool.release,在 refs.size === 0 时调用 startDrainTimer —— 而 startDrainTimer 无条件写入 state = 'draining',等于把重启刚刚取消掉的定时器重新装上了。随之而来两种错误结果:一个健康重启完成的传输被留在 refs 为 0 的 draining 状态,30 秒后被强制关闭,除非恰好有轮次边界及时重新获取;或者,如果 drain 定时器在重启的 await 期间触发,forceShutdown 会关闭 entry、onClosed 会把它从 pool.entries 中移除,之后 doRestartInner 继续执行到 client.connect(),派生出一个池已不再跟踪的子进程 —— 这正是 mcp-pool-entry.ts:960-975 注释所要防止的孤儿进程。同一分支还会记录一条失败,于是下一轮的恢复会对一个仍处于 CONNECTING 的 entry 调用 acquireForRecovery,并给一个从未真正故障的服务加上 5 秒冷却。改动前该分支只删除句柄并抛错。

证据见上方 probe(直接驱动 manager 层;PoolEntry/McpTransportPool 层通过阅读源码核实):handle.release 调用 1 次、pooledConnections.has(srv) 为 false、failedPooledConnections 被写入、acquireForRecoveryrecordRecoveryFailure 各被调用一次。diff 的 - 侧显示改动前该分支只做 pooledConnections.delete(serverName); throw

修复方式见上方代码块:不要把正在重启的 entry 当成失败;向池询问句柄背后的 entry 是否处于重启中(在 PooledConnection 上暴露 restartInProgress,或新增一个池自有的 reportStaleHandle(handle),对非终态的重启 entry 直接 no-op),此时只抛出既有错误,不调用 pooled.release()、也不改动 failedPooledConnections;退一步说,也应以 entry 处于终态('failed' / 'closed')作为释放条件,而不是仅看 client.getStatus()

任何修复都必须在重启期间保持座位占用(或以其他方式避免 refs.size === 0),因为 startDrainTimermcp-pool-entry.ts:711-712)会无条件覆盖 doRestartInner:978 建立的 'active' 状态,而 McpTransportPool.release 正是经 mcp-transport-pool.ts:590-595 到达它。

请补一个用例:安装一个 client.getStatus() 返回 CONNECTING、而池报告 entry 正在重启的句柄,然后断言 (a) handle.release 未被调用、(b) failedPooledConnections 中没有该服务的记录、(c) 调用仍以 pool entry disconnected 拒绝。移除重启保护后 (a) 与 (b) 必须变红。

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

Comment on lines +3319 to +3321
const replaced = this.pooledConnections.has(name) || this.clients.has(name);
this.failedPooledConnections.delete(name);
this.recoveryNotices.delete(name);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R3-1: [fails-closed] [new-surface] A runtime add wipes the server's failure record and retained notice before it acquires the transport, so when the acquire then fails the rollback leaves a still-configured server that can never recover again. Lines 3320-3321 clear both maps, pool.acquire fails (likely — it is the same server that just died, or the budget refuses), and the catch at :3424 removes the runtime overlay and throws McpServerSpawnFailedError. The server is still configured from the settings layer, but nothing can re-record the failure: failedPooledConnections is only repopulated by the pool 'failed' event on a tracked connection (:1786) or by readResource's health check on an existing pooled handle (:2849), and this server now has neither. Every later model send therefore short-circuits at failedPooledConnections.size === 0, so the 5-second-cooldown retry loop this PR exists to add never runs for it again, even after the operator restarts the MCP server. On the success path the two deletes are redundant anyway, because trackPooledConnection already clears both at :1792-1793 — their only net effect is the destructive one on failure.

Witness:

[probe — built McpClientManager, settings-layer srv with a held failure record]
 S2 held failure record, failedPooled = ["srv"]
 S2 addRuntimeMcpServer (acquire fails) -> McpServerSpawnFailedError: Failed to spawn MCP server 'srv'
 S2 still configured? getMcpServers() = ["srv"]
 S2 failedPooled after failed add = [] | acquireForRecovery calls = [] | notices = []
Fix flip (delete the two pre-acquire lines):
 failedPooled after failed add = ["srv"] | acquireForRecovery calls = ["srv"] | notice 'reconnected'
// Delete both pre-acquire clears and let trackPooledConnection clear the record
// on success (:1792-1793 already does). A stale record left behind by a failed
// add is inert: stillWanted() skips records whose transportId no longer matches
// the effective config (:1821), and the next discovery pass prunes mismatched
// records (:1639-1644).

trackPooledConnection already deletes both maps on the success path (mcp-client-manager.ts:1792-1793), so the fix must not remove that clearing.

Please add a case on the settings-layer fixture: discover, f.fail(), f.pool.acquire.mockRejectedValueOnce(new Error('spawn failed')), assert addRuntimeMcpServer rejects, then assert recoverFailedConnections still calls acquireForRecovery for srv. It is red without the fix; no existing test pins the current pre-acquire clear.

中文说明

[Critical] R3-1:[fails-closed] [new-surface] 运行时添加会在获取传输之前就抹掉该服务的失败记录与保留的通知,因此当随后的 acquire 失败时,回滚逻辑会留下一个仍然被配置着、却再也无法恢复的服务。3320-3321 行清空了这两个 map,pool.acquire 失败(很可能 —— 它就是刚刚挂掉的那个服务,或者预算拒绝),:3424 的 catch 移除运行时覆盖层并抛出 McpServerSpawnFailedError。该服务在 settings 层仍然是被配置的,但没有任何途径能重新记录这次失败:failedPooledConnections 只会被已跟踪连接上的池 'failed' 事件(:1786)或 readResource 对既有池句柄的健康检查(:2849)重新写入,而这个服务两者都没有了。于是之后每一次模型发送都会在 failedPooledConnections.size === 0 处短路,本 PR 所要新增的“5 秒冷却重试循环”对它再也不会运行 —— 即便运维已经重启了 MCP 服务。而在成功路径上这两次删除本来就是多余的,因为 trackPooledConnection 已在 :1792-1793 清空了两者;它们唯一的净效果就是失败时那次破坏性的清除。

证据见上方 probe:失败添加后 failedPooled 为空、acquireForRecovery 调用为空、通知为空;删掉这两行预清除后,failedPooled 保留 ["srv"]acquireForRecovery 被调用、并出现 reconnected 通知。

修复方式见上方代码块:删除这两处 acquire 之前的清除,交给 trackPooledConnection 在成功时清理(:1792-1793 已经在做)。失败添加留下的过期记录是惰性的:stillWanted() 会跳过 transportId 与生效配置不再匹配的记录(:1821),下一轮发现也会清理不匹配的记录(:1639-1644)。

trackPooledConnection 已在成功路径上删除这两个 map(mcp-client-manager.ts:1792-1793),因此修复不得移除那份清理。

请在 settings 层 fixture 上补一个用例:发现、f.fail()f.pool.acquire.mockRejectedValueOnce(new Error('spawn failed')),断言 addRuntimeMcpServer 拒绝,然后断言 recoverFailedConnections 仍会为 srv 调用 acquireForRecovery。没有修复时它会变红;现有测试没有任何一个钉住当前的预清除行为。

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

Comment on lines +3485 to +3487
this.runtimeMcpAddTokens.delete(name);
this.failedPooledConnections.delete(name);
this.recoveryNotices.delete(name);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R2-11: (fix-induced) [certifies-falsely] [new-surface] The fix for the previously reported gap — removeRuntimeMcpServer being the one removal path that did not clear the two recovery maps — put all three cleanup deletes above the not_present early return, so a documented no-op remove now has two destructive side effects. (1) It wipes recovery state for a server that is still configured: a settings-layer (.mcp.json) server whose pooled transport dropped holds an entry in failedPooledConnections plus a retained notice; a runtime-remove call for that name returns {skipped: true, reason: 'not_present'} having changed nothing else, but both deletes already ran, so recoverFailedConnections exits at size === 0, the retained notice is never surfaced, and readResource keeps throwing pool connection unavailable; retry after discovery with no discovery ever scheduled. The server stays dead until a full discovery pass. This input is expected, not exotic: bridge.ts:14505 forwards the client-supplied name unfiltered, acpAgent.ts:12254 and the fan-out at :12270 call it on the workspace manager and every active session's manager, and this PR's own test at :4777-4789 removes 'ghost', a name that is not a runtime entry. (2) It cancels a valid parked add: addRuntimeMcpServer sets its token, then parks at await previous (:3121-3128) for the whole in-flight discovery pass and has not yet written the overlay (that happens at :3349). A DELETE for the same name — a route with no existence precondition (workspace-mcp-control.ts:348) — deletes the token and returns not_present. When discovery settles, the add's stillCurrent() is false and it throws InvalidMcpConfigError(name, 'runtime add was superseded'), which acpAgent.ts:2694-2699 maps to RequestError(-32099, ..., {errorKind: 'invalid_config'}). The caller is told its configuration is invalid for a configuration that is valid, and the server never registers. client-mcp-sender-registry.ts:472-478 is a shipped caller that explicitly relies on not_present being a harmless no-op.

Witness:

[probe — two independent arms, built McpClientManager]
ARM 1  CONTROL (no remove): acquireForRecovery = ["srv"], notices = ["MCP server 'srv' reconnected..."]
ARM 1  removeRuntimeMcpServer(settings-layer srv) -> {"name":"srv","skipped":true,"reason":"not_present"}
       still configured? getMcpServers() = ["srv"]
       failedPooled after not_present remove = []
       recoverFailedConnections -> notices = [] | acquireForRecovery calls = []
       readResource(srv) -> MCP server 'srv' pool connection unavailable; retry after discovery.
ARM 1  with the relocation fix: failedPooled = ["srv"], acquireForRecovery = ["srv"], notice 'reconnected'
ARM 2  CONTROL (no remove): add RESOLVED {"name":"foo",...,"toolCount":1}, overlay written = true
ARM 2  with a not_present remove during the park:
       removeRuntimeMcpServer('foo') -> {"name":"foo","skipped":true,"reason":"not_present"}
       add REJECTED name=InvalidMcpConfigError code=invalid_config
       message = Invalid MCP server config for 'foo': runtime add was superseded
       pooledConnections keys = ["boot"] | foo in overlay = false

Each fix alone leaves the other arm broken, so both consequences close only when all three deletes move.

// Move all three deletes below the early return, so only a real removal clears
// recovery state or cancels an add:
const wasRuntime = this.cliConfig.removeRuntimeMcpServer(name);
if (!wasRuntime) {
  return { name, skipped: true, reason: 'not_present' };
}
this.runtimeMcpAddTokens.delete(name);
this.failedPooledConnections.delete(name);
this.recoveryNotices.delete(name);

Two premises the fix rests on: mcp-client-manager.test.ts:5006 (drops retained failure notices when a runtime server is removed) requires both maps to still be cleared when wasRuntime is true, so relocate the deletes rather than removing them; and client-mcp-sender-registry.ts:467 records that Config.removeRuntimeMcpServer is not owner-scoped, so the case where the add has written its overlay must still let remove win and abort the add, or the removed server comes back. If remove-wins over a queued add is intended even for not_present, throw a distinct transient error rather than InvalidMcpConfigError so the ACP surface does not certify a valid configuration as invalid.

Please add two cases: (a) on the settings-layer fixture, discover, f.fail(), make acquireForRecovery reject once so a notice is retained, call removeRuntimeMcpServer and assert not_present, then assert a following recoverFailedConnections still calls acquireForRecovery for srv; (b) start an add while a discovery pass is in flight so it parks at await previous, call removeRuntimeMcpServer(name), assert not_present, then let discovery settle and assert the add still resolves with the server registered. Each goes red with the deletes left above the early return.

中文说明

[Critical] R2-11:(fix-induced) [certifies-falsely] [new-surface] 针对先前提出的缺口(removeRuntimeMcpServer 是唯一没有清理那两个恢复 map 的移除路径)所做的修复,把三处清理删除都放在了 not_present 提前返回之上,于是一个被文档明确定义为空操作的移除现在有了两个破坏性副作用。(1)它会为一个仍被配置的服务抹掉恢复状态:一个 settings 层(.mcp.json)服务在其池传输掉线后,failedPooledConnections 中有一条记录、并保留了一条通知;针对该名字的运行时移除调用返回 {skipped: true, reason: 'not_present'}、表面上什么都没改,但两次删除已经执行,于是 recoverFailedConnectionssize === 0 处返回,保留的通知永远不会被呈现,readResource 持续抛出 pool connection unavailable; retry after discovery,而没有任何发现流程被安排。该服务会一直死着,直到某次完整发现。(2)它会取消一个合法的、正在等待的添加:addRuntimeMcpServer 先设置 token,然后在 await previous(:3121-3128)上停住,等待整个在飞的发现流程,此时还没写覆盖层(写入发生在 :3349)。一个针对同名的 DELETE(该路由没有存在性前置条件,workspace-mcp-control.ts:348)会删掉 token 并返回 not_present。发现流程结束后,添加的 stillCurrent() 为 false,于是抛出 InvalidMcpConfigError(name, 'runtime add was superseded'),而 acpAgent.ts:2694-2699 会把它映射为 RequestError(-32099, ..., {errorKind: 'invalid_config'})。调用方被告知它的配置无效,而该配置其实是有效的,服务也永远不会注册。client-mcp-sender-registry.ts:472-478 是一个已上线的调用方,明确依赖 not_present 是无害的空操作。

证据见上方 probe(两个独立分支):分支 1 中 not_present 移除后 failedPooled 变空、acquireForRecovery 无调用、readResource 抛错,套用移位修复后全部恢复;分支 2 中停等期间的 not_present 移除使添加以 InvalidMcpConfigError / code=invalid_config 被拒、覆盖层为空,而对照组正常 resolve 且 toolCount: 1。两个修复各自单独应用都会让另一分支保持损坏,因此只有三处删除一起下移才能同时闭合。

修复方式见上方代码块:把三处删除移到提前返回之后。

修复所依赖的两个前提:mcp-client-manager.test.ts:5006drops retained failure notices when a runtime server is removed)要求当 wasRuntime 为 true 时两个 map 仍被清理,所以应当移位而不是删除;client-mcp-sender-registry.ts:467 记录了 Config.removeRuntimeMcpServer 不是按 owner 限定作用域的,因此在添加已经写入覆盖层的情形下,仍必须让移除胜出并中止添加,否则被移除的服务会复活。如果确实希望即使 not_present 也让移除压过排队中的添加,请抛出一个区分性的临时错误而不是 InvalidMcpConfigError,以免 ACP 面把有效配置认定为无效。

请补两个用例:(a) 在 settings 层 fixture 上发现、f.fail()、让 acquireForRecovery 拒绝一次以保留通知,调用 removeRuntimeMcpServer 并断言 not_present,随后断言再一次 recoverFailedConnections 仍会为 srv 调用 acquireForRecovery;(b) 在一次发现流程在飞时启动添加使其停在 await previous,调用 removeRuntimeMcpServer(name) 断言 not_present,然后让发现结束并断言添加仍然成功且服务已注册。若三处删除仍留在提前返回之上,两个用例都会变红。

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

Comment on lines +5645 to +5646
expect(notices[0].message).toContain('remains disconnected');
expect(notices[0].message).toContain('authentication');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R3-16: The toContain('authentication') assertion is satisfied by hardcoded boilerplate in the production notice, not by the mocked failure reason, so it pins nothing about error propagation. The test at :5635 mocks f.pool.acquireForRecovery.mockRejectedValueOnce(new Error('authentication required')) and then asserts the notice contains 'authentication' — but the notice text is a fixed template (mcp-client-manager.ts:1865, "…retry a later turn after 5 seconds or check MCP configuration and authentication.") and getErrorMessage(error) at :1859 is passed only to debugLogger.error, never interpolated. Two concrete costs: the assertion reads as "the failure cause reaches the operator/model notice" yet would stay green if the cause were dropped entirely (it already is), so a regression that stops surfacing why a server stayed down is invisible here — the sibling test at :5393 and the cooldown-refusal path both funnel into the same causeless string; and it is brittle in the other direction, since an unrelated copy edit of that template (say "check MCP configuration and credentials") turns this test red as if behaviour had regressed.

Witness:

[probe]
Rewriting the mocked reason in the test from new Error('authentication required')
to new Error('zzz-cause-without-the-word') leaves the test GREEN:
  Tests 1 passed | 178 skipped (179)
-> the assertion's truth value does not depend on the failure cause at all.
Reading confirms the match source: :1866 is the fixed template, and
getErrorMessage(error) at :1859 goes only to debugLogger.error.
// Pick one and make the assertion match the contract.
// (a) keep the generic notice and assert a fragment actually diagnostic of this
//     branch, dropping the 'authentication' line:
//       expect(notices[0].message).toContain('cooling down');
// (b) or, if the cause is meant to reach the notice, interpolate it at
//     mcp-client-manager.ts:1865 (append getErrorMessage(error)) and tighten:
//       expect(notices[0].message).toContain('authentication required');

Option (b) must keep the BudgetExhaustedError branch (mcp-client-manager.ts:1863-1864, "the workspace MCP client budget is exhausted…") on its own wording, because the sibling test asserts expect(notices[0].message).not.toContain('authentication') at :5671 — the budget-refusal notice must not inherit the generic branch's "authentication" text. Note also that option (a)'s 'cooling down' fragment lives in the same generic template, so it discriminates this branch from the budget branch but still not the cause.

Under (b), changing the assertion to toContain('authentication required') must go RED against the current code (the reason is not interpolated today) and green only after the notice carries it; under (a), removing 'cooling down' from the template must go red.

中文说明

[Suggestion] R3-16:toContain('authentication') 这条断言是被生产通知里的硬编码套话满足的,而不是被 mock 出来的失败原因满足的,因此它对错误传播什么也没钉住。:5635 的测试 mock 了 f.pool.acquireForRecovery.mockRejectedValueOnce(new Error('authentication required')),随后断言通知中包含 'authentication' —— 但通知文本是固定模板(mcp-client-manager.ts:1865,“……retry a later turn after 5 seconds or check MCP configuration and authentication.”),而 :1859 的 getErrorMessage(error) 只传给了 debugLogger.error,从未被插入模板。两点具体代价:这条断言读起来像“失败原因会到达运维/模型可见的通知”,但即使原因被完全丢弃(现状就是如此)它依然为绿,因此一个“不再呈现服务为何持续下线”的回归在这里是看不见的 —— :5393 的姊妹测试与冷却拒绝路径都汇入同一条不含原因的字符串;另一方面它也很脆,因为对该模板一次无关的文案修改(比如改成“check MCP configuration and credentials”)就会让这条测试变红,仿佛行为发生了回归。

证据见上方 probe:把测试中 mock 的原因从 new Error('authentication required') 改写为 new Error('zzz-cause-without-the-word'),测试仍为绿(1 通过 / 178 跳过)—— 说明该断言的真假与失败原因完全无关;阅读亦确认匹配来源是 :1866 的固定模板,而 :1859 的 getErrorMessage(error) 只进入 debugLogger.error

修复方式见上方代码块:二选一,并让断言与契约相符。(a) 保留泛化通知,改为断言真正能判别该分支的片段并去掉 'authentication' 那行(expect(notices[0].message).toContain('cooling down'));(b) 或者,如果本意是让原因进入通知,则在 mcp-client-manager.ts:1865 插入它(追加 getErrorMessage(error)),并把断言收紧为 toContain('authentication required')

选择 (b) 时必须让 BudgetExhaustedError 分支(mcp-client-manager.ts:1863-1864,“the workspace MCP client budget is exhausted…”)保持自己的措辞,因为姊妹测试在 :5671 断言 expect(notices[0].message).not.toContain('authentication') —— 预算拒绝的通知不得继承泛化分支的 “authentication” 文案。另请注意 (a) 中的 'cooling down' 片段位于同一条泛化模板内,因此它能把该分支与预算分支区分开,但仍不能区分原因。

在 (b) 之下,把断言改为 toContain('authentication required') 必须在当前代码下变红(今天原因并未被插入),且只有在通知携带它之后才转绿;在 (a) 之下,从模板中移除 'cooling down' 必须变红。

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

Comment on lines +373 to +374
it.each(cleanupTransports.filter(({ transport }) => transport !== 'sse'))(
'releases all $transport registry seats under the logical session only',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R3-17: This releaseSession matrix silently drops the SSE case with no comment and no recorded rationale, while the two neighbouring matrices in the same block (waits for evicted $transport cleanup… at :438, rejects a waiting $transport replacement… at :485) run all three transports unfiltered. A maintainer cannot tell whether SSE is deliberately out of scope for seat teardown or was filtered because the case is red, and nothing in the tree records the answer: HEAD's commit message is a single subject line with no body, and git log -S"transport !== 'sse'" shows the filter arriving with the block itself. The pool has no sse-specific branch — defaultPoolEntryOptions classifies http and sse identically (transport === 'http' || transport === 'sse' || transport === 'websocket', mcp-pool-entry.ts:82) and POOLED_TRANSPORTS_DEFAULT = new Set(['stdio','websocket']) (mcp-pool-key.ts:38) sends both down the same unpooled path — so the exclusion is either free coverage being dropped or a failing case being hidden.

Witness:

[probe — isolated copy; remove `.filter(({ transport }) => transport !== 'sse')`
 at :373, no other change, then --reporter=verbose]
 ✓ … > releases all 'stdio' registry seats under the logical session only   12ms
 ✓ … > releases all 'http'  registry seats under the logical session only    4ms
 ✓ … > releases all 'sse'   registry seats under the logical session only    4ms  <- the excluded case, GREEN
 full file: Tests 72 passed (72)

The probe settles which branch of the dichotomy applies: the SSE case passes, so this is free coverage being dropped rather than a red case being hidden. Swept all 57 PR comments for !== 'sse' — no hits, so this is not already under discussion.

it.each(cleanupTransports)(
  'releases all $transport registry seats under the logical session only',
  // …
);

Drop the .filter(...) and run all three transports — the run above shows the third case exists and passes once the filter is removed, which is the witness this finding asks for. If it ever genuinely cannot pass, keep the filter and add a one-line comment naming the reason, in the style the block already uses (// Handle release clears refs and the session index before cleanup ends.).

中文说明

[Suggestion] R3-17:这个 releaseSession 矩阵静默地去掉了 SSE 用例,既无注释也无任何记录在案的理由,而同一块中相邻的两个矩阵(:438 的 waits for evicted $transport cleanup…、:485 的 rejects a waiting $transport replacement…)都是不加过滤地跑全部三种传输。维护者无法判断 SSE 是被有意排除在座位拆除之外,还是因为该用例会红而被过滤掉,而仓库中也没有任何地方记录答案:HEAD 的提交信息只有一行标题、没有正文,git log -S"transport !== 'sse'" 显示这个过滤是随该代码块一起引入的。池中没有针对 sse 的分支 —— defaultPoolEntryOptions 对 http 与 sse 的分类完全相同(transport === 'http' || transport === 'sse' || transport === 'websocket'mcp-pool-entry.ts:82),且 POOLED_TRANSPORTS_DEFAULT = new Set(['stdio','websocket'])mcp-pool-key.ts:38)会把两者送入同一条非池化路径 —— 因此这个排除要么是被丢弃的免费覆盖,要么是被隐藏起来的失败用例。

证据见上方 probe:在隔离副本中移除 :373 的 .filter(({ transport }) => transport !== 'sse')、不作其它改动后以 --reporter=verbose 运行,三个用例全部为绿(stdio 12ms、http 4ms、被排除的 sse 4ms),整个文件 72 项通过。该 probe 确定了二分法中适用的是哪一支:SSE 用例是可以通过的,所以这是被丢弃的免费覆盖,而不是被隐藏的红用例。已对全部 57 条 PR 评论检索 !== 'sse',无命中,因此本条并非已在讨论中的问题。

修复方式见上方代码块:去掉 .filter(...),跑全部三种传输 —— 上方运行结果表明过滤移除后第三个用例确实存在并通过,这正是本条所要求的见证。如果它将来确实无法通过,则保留过滤并补一行注释说明原因,风格照该块已有写法(// Handle release clears refs and the session index before cleanup ends.)。

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

Comment on lines +490 to +492
await this.mcpClientManager.recoverFailedConnections(signal, {
consumeNotices: false,
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R3-26: refreshMcpTools runs recovery on every registry in the inheritance chain — including a subagent registry that owns its own pool projections — with consumeNotices: false, but the only drainer of recoveryNotices in the whole tree is the session-root manager, so agent-scoped recovery notices are produced and then silently discarded forever. A subagent declaring per-agent mcpServers gets its own McpClientManager (every ToolRegistry builds one, tool-registry.ts:242-246) and its own pool seats (subagent-manager.ts:1074-1112), so when that server's transport drops, onFailed records it in the child's failedPooledConnections. On the next agent round agent-core.ts:976 reaches this line and recovers against the child's manager; whether the recovery succeeds ("MCP server 'x' reconnected. Cancelled calls were not replayed.") or fails ("… remains disconnected … retry a later turn after 5 seconds"), the message is written into that manager's recoveryNotices and left there. The single consuming caller is Session.ts:7733-7736, which resolves this.config.getToolRegistry().getMcpClientManager() — the session registry's manager, a different object — and the only other caller (mcp-client-manager.ts:2825) also passes consumeNotices: false. So the operator never learns an agent-scoped MCP server is stuck disconnected and never receives the documented retry guidance, while the identical event on a session-level server is surfaced as an ACP diagnostic; the notices simply accumulate until subagentRegistry.stop() clears them, and refreshMcpTools returns Promise<void> so no caller could forward them even if it wanted to. The design doc's "Child and resource callers leave notices pending for ACP" holds for an inheriting child (notices stay pending in the root's manager, which Session.ts does read) but cannot hold for a child that owns its discovery — and item 6 of the same doc promises to "expose success/failure as an ACP diagnostic".

Witness:

not run — the probe I would have written (seed a failed pooled connection on the
CHILD's manager, call child.refreshMcpTools(signal), assert nothing observable
carries the notice) needs an isolated tree, and `review scratch-tree` returned
available: false (the repo's local git config carries an includeIf chain to a
missing credentials file, so the harness refused to create or reset any checkout).
Confirmation rests on an exhaustive caller enumeration instead: 3 production call
sites of recoverFailedConnections (Session.ts:7733-7736 the ONLY consumer;
tool-registry.ts:490 and mcp-client-manager.ts:2825 both consumeNotices:false),
each ToolRegistry builds its own McpClientManager (tool-registry.ts:242-246), and
refreshMcpTools is typed Promise<void> (:485).
// Make refreshMcpTools return the notices it collected, concatenating the recursive
// source result with this level's recoverFailedConnections result:
//   Promise<Array<{ serverName: string; message: string }>>
// and have the agent-core.ts:976 round-loop caller forward them into the same
// diagnostic channel the session uses (AgentEventType / the session's
// mcpRecoveryNotices sink) instead of dropping the value.

Session.ts:7740-7757 dedups notices per turn via this.mcpRecoveryNotices keyed on serverName and resets it when mcpRecoveryNoticeTurn !== recoveryTurnId, so forwarding subagent notices into that same map must keep the serverName key unique across session and agent scopes — otherwise an agent-scoped "MCP server 'x' remains disconnected…" suppresses the session-scoped notice for the same server name (if (this.mcpRecoveryNotices.get(notice.serverName) === notice.message) continue;).

Please add a case to tool-registry.mcp-recovery.test.ts that seeds a failed pooled connection on the child's manager (not the source's), calls child.refreshMcpTools(signal), and asserts the returned/emitted notice array contains the child manager's MCP server 'server' reconnected… text. Reverting to Promise<void> must make that assertion fail; the existing expect(recover).toHaveBeenCalledWith(signal, { consumeNotices: false }) at :121 stays green either way and pins nothing about delivery.

中文说明

[Suggestion] R3-26:refreshMcpTools 会在继承链上的每一个注册表上运行恢复 —— 包括拥有自己池投影的子 agent 注册表 —— 并传入 consumeNotices: false,但整棵树中唯一会消费 recoveryNotices 的是会话根 manager,因此 agent 范围的恢复通知被生成之后就被永久静默丢弃。声明了 per-agent mcpServers 的子 agent 会拥有自己的 McpClientManager(每个 ToolRegistry 都会构建一个,tool-registry.ts:242-246)和自己的池座位(subagent-manager.ts:1074-1112),所以当那个服务的传输掉线时,onFailed 会把它记录进子级failedPooledConnections。下一轮 agent 回合中 agent-core.ts:976 走到这一行、针对子级 manager 执行恢复;无论恢复成功(“MCP server 'x' reconnected. Cancelled calls were not replayed.”)还是失败(“… remains disconnected … retry a later turn after 5 seconds”),该消息都会被写入那个 manager 的 recoveryNotices 并留在那里。唯一的消费调用方是 Session.ts:7733-7736,它解析的是 this.config.getToolRegistry().getMcpClientManager() —— 即会话注册表的 manager,是另一个对象 —— 而另一个调用方(mcp-client-manager.ts:2825)同样传入 consumeNotices: false。于是运维永远不知道某个 agent 范围的 MCP 服务卡在断开状态,也永远收不到文档承诺的重试指引;而会话级服务上的同类事件会作为 ACP 诊断呈现出来。这些通知只会一直累积,直到 subagentRegistry.stop() 清空它们;并且 refreshMcpTools 的返回类型是 Promise<void>,所以即便调用方想转发也无从转发。设计文档所说的“子级与资源调用方把通知留给 ACP 待消费”对继承型子级成立(通知留在根 manager 中,而 Session.ts 确实会读取它),但对拥有自己发现流程的子级不成立 —— 而同一文档第 6 条承诺“把成功/失败作为 ACP 诊断暴露出来”。

证据:not run —— 我本来要写的 probe(在子级 manager 上植入一个失败的池连接、调用 child.refreshMcpTools(signal)、断言没有任何可观察载体携带该通知)需要一个隔离树,而 review scratch-tree 返回 available: false(仓库本地 git 配置带有指向缺失凭据文件的 includeIf 链,因此该工具拒绝创建或重置任何检出)。确认改为依据穷尽的调用方枚举:recoverFailedConnections 的 3 个生产调用点(Session.ts:7733-7736 是唯一消费者;tool-registry.ts:490mcp-client-manager.ts:2825 都传 consumeNotices:false)、每个 ToolRegistry 自建 McpClientManagertool-registry.ts:242-246)、refreshMcpTools 的类型是 Promise<void>(:485)。

修复方式见上方代码块:让 refreshMcpTools 返回它收集到的通知(把递归的源结果与本层 recoverFailedConnections 的结果拼接,类型为 Promise<Array<{ serverName: string; message: string }>>),并让 agent-core.ts:976 的轮次循环调用方把它们转发进会话所用的同一诊断通道(AgentEventType / 会话的 mcpRecoveryNotices 汇),而不是丢弃该值。

Session.ts:7740-7757 通过以 serverName 为键的 this.mcpRecoveryNotices 按轮次去重,并在 mcpRecoveryNoticeTurn !== recoveryTurnId 时重置,因此把子 agent 的通知转发进同一个 map 时,必须保证 serverName 键在会话范围与 agent 范围之间唯一 —— 否则一条 agent 范围的“MCP server 'x' remains disconnected…”会压掉同一服务名在会话范围的通知(if (this.mcpRecoveryNotices.get(notice.serverName) === notice.message) continue;)。

请在 tool-registry.mcp-recovery.test.ts 中补一个用例:在子级 manager(而非源 manager)上植入失败的池连接,调用 child.refreshMcpTools(signal),断言返回/发出的通知数组包含子级 manager 的 MCP server 'server' reconnected… 文本。改回 Promise<void> 后该断言必须失败;既有的 expect(recover).toHaveBeenCalledWith(signal, { consumeNotices: false })(:121)两种情况下都为绿,对“是否送达”什么也没钉住。

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

Comment on lines +1176 to +1178
const signal = options?.signal ?? new AbortController().signal;
await owner.refreshMcpTools(signal);
signal.throwIfAborted();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R3-28: readMcpResource aims the recovery refresh at the resolved owner (the top of the inheritance chain), so the recovery replaces tool objects upstream but never re-syncs the calling registry's own copiedMcpTools/tools maps — the caller keeps holding pre-recovery instances. For a root owner, copiedMcpToolsSource is undefined, so inside refreshMcpTools the recursive await source?.refreshMcpTools(signal) is a no-op and the guard at :493 (if (signal.aborted || !source || source !== this.copiedMcpToolsSource) return;) exits on !source before both re-sync loops at :500-527. The recovery itself does replace the root's tool objects (recoverFailedConnectionsacquireForRecoveryattachview.applyTools(this.toolsSnapshot), mcp-pool-entry.ts:617), so the root holds fresh instances bound to the new client while the child keeps the old ones — and those are not re-pointing façades: mcp-tool.ts:651 is this.mcpClient!.callTool(…), a captured McpDirectClient. Concretely: server foo dies mid-round, the subagent reads @foo:some://resource, the walk resolves owner = root, root recovers and re-registers fresh foo tools, the read succeeds and the post-await inheritedFrom re-check passes. When the model then calls mcp__foo__bar later in the same round, child.getTool('mcp__foo__bar') is still the stale object and the call goes out on the released client — failing through the new !this.reconnectOnError && this.isConnectionError(error) branch with SHARED_CONNECTION_ERROR_MESSAGE ("its outcome may be unknown … Do not retry automatically") for a call that went out on an already-released client. The child only picks up the fresh object at its next round (agent-core.ts:973-976). Reachable in production: ReadMcpResourceTool resolves config.getToolRegistry(), which for a subagent is the copied child registry installed by rebuildToolRegistryOnOverride (tools/agent/agent.ts:527-534).

Witness:

not run — the discriminating probe (spy recoverFailedConnections to swap the source's
instances, then assert child.getTool(fresh.name) after child.readMcpResource(...),
red on `owner.` / green on `this.`) needs a scratch tree, and `review scratch-tree`
returned available: false. Confirmation rests on the quoted early return at
tool-registry.ts:493, which for a root owner (copiedMcpToolsSource === undefined)
exits before both re-sync loops, and on mcp-tool.ts:651 being a captured client
rather than a re-pointing façade.
const signal = options?.signal ?? new AbortController().signal;
await this.refreshMcpTools(signal);
signal.throwIfAborted();

The recursion inside refreshMcpTools (await source?.refreshMcpTools(signal), :488) already reaches owner, so the recovery still happens before the read; the added effect is that child (and any intermediate registry) swaps to the post-recovery tool objects.

The existing parameterised case at tool-registry.mcp-recovery.test.ts:78vi.spyOn(source, 'refreshMcpTools').mockImplementation(async () => {…}) — depends on the source's refreshMcpTools sitting on the await path so the policy flips land before the re-check, and asserts rejects.toThrow('no longer available') for all four of disabled/pending/untrusted/stopped. Retargeting the call to this keeps that spy on the path via :488, and the stopped variant is still caught by the source !== this.copiedMcpToolsSource guard at :489 — all four must stay green.

Please add a case alongside reads inherited resources through their owning pool manager: copy a tool from source into child, spy source.getMcpClientManager().recoverFailedConnections with an implementation that does source.removeMcpToolsByServer('server'); source.registerTool(fresh.tool), then await child.readMcpResource('server', 'test://resource') and assert child.getTool(fresh.tool.name)).toBe(fresh.tool). That assertion is red with owner.refreshMcpTools(signal) and green with this.refreshMcpTools(signal).

中文说明

[Suggestion] R3-28:readMcpResource 把恢复刷新指向解析出的 owner(继承链顶端),因此恢复只替换了上游的工具对象,却从不同步调用方注册表自身的 copiedMcpTools/tools —— 调用方继续持有恢复前的实例。对根 owner 而言 copiedMcpToolsSourceundefined,于是在 refreshMcpTools 内部递归的 await source?.refreshMcpTools(signal) 是空操作,而 :493 的守卫(if (signal.aborted || !source || source !== this.copiedMcpToolsSource) return;)会因 !source 在 :500-527 两个重新同步循环之前就返回。恢复本身确实替换了根的工具对象(recoverFailedConnectionsacquireForRecoveryattachview.applyTools(this.toolsSnapshot)mcp-pool-entry.ts:617),因此根持有绑定新 client 的新实例,而子级仍持有旧实例 —— 并且它们不是会重新指向的外壳:mcp-tool.ts:651this.mcpClient!.callTool(…),一个被捕获的 McpDirectClient。具体场景:服务 foo 在轮次中途挂掉,子 agent 读取 @foo:some://resource,链路解析出 owner = root,root 恢复并重新注册全新的 foo 工具,读取成功、await 之后的 inheritedFrom 复检也通过。随后模型在同一轮次里调用 mcp__foo__bar 时,child.getTool('mcp__foo__bar') 仍是那个过期对象,调用会在已释放的 client 上发出 —— 并经由新的 !this.reconnectOnError && this.isConnectionError(error) 分支以 SHARED_CONNECTION_ERROR_MESSAGE(“其结果可能未知……不要自动重试”)失败,而这是一次发往已释放 client 的调用。子级要到下一轮(agent-core.ts:973-976)才会拿到新对象。生产可达:ReadMcpResourceTool 解析 config.getToolRegistry(),对子 agent 而言那正是 rebuildToolRegistryOnOverride 安装的复制子注册表(tools/agent/agent.ts:527-534)。

证据:not run —— 具有判别力的 probe(对 recoverFailedConnections 打桩以替换源的实例,然后在 child.readMcpResource(...) 之后断言 child.getTool(fresh.name)owner. 为红、this. 为绿)需要隔离树,而 review scratch-tree 返回 available: false。确认依据是 tool-registry.ts:493 那处被引用的提前返回(对根 owner,即 copiedMcpToolsSource === undefined,它在两个重新同步循环之前就退出),以及 mcp-tool.ts:651 是被捕获的 client 而非重新指向的外壳。

修复方式见上方代码块:把 await owner.refreshMcpTools(signal); 改为 await this.refreshMcpTools(signal);signal.throwIfAborted() 与既有的 await 后 inheritedFrom 复检保持不变。refreshMcpTools 内部的递归(:488 的 await source?.refreshMcpTools(signal))本就会到达 owner,因此恢复仍在读取之前发生;额外效果是 child(以及任何中间注册表)会换到恢复后的工具对象。

tool-registry.mcp-recovery.test.ts:78 既有的参数化用例 —— vi.spyOn(source, 'refreshMcpTools').mockImplementation(async () => {…}) —— 依赖源的 refreshMcpTools 位于 await 路径上,使策略翻转发生在复检之前,并对 disabled/pending/untrusted/stopped 四种情形断言 rejects.toThrow('no longer available')。把调用改为 this 之后,该 spy 仍通过 :488 留在路径上,而 stopped 变体仍由 :489 的 source !== this.copiedMcpToolsSource 守卫捕获 —— 四者都必须保持为绿。

请在 reads inherited resources through their owning pool manager 旁补一个用例:把工具从 source 复制到 child,对 source.getMcpClientManager().recoverFailedConnections 打桩,其实现执行 source.removeMcpToolsByServer('server'); source.registerTool(fresh.tool),然后 await child.readMcpResource('server', 'test://resource') 并断言 child.getTool(fresh.tool.name)).toBe(fresh.tool)。该断言在 owner.refreshMcpTools(signal) 下为红、在 this.refreshMcpTools(signal) 下为绿。

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

Comment on lines +153 to +155
it('preserves agent-owned tools and does not inherit tools from an overridden server', async () => {
const sourceConfig = makeConfig({ server: { command: 'parent-mcp' } });
const childConfig = makeConfig({ server: { command: 'agent-mcp' } });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R3-27: The new inheritance gate's mcpSessionMetadataKey conjunct — the only discriminator for a same-transport, different-projection override — is exercised by no test, so deleting it keeps the whole suite green while excluded tools become callable and auto-approved in the subagent. Every divergence case in this file differs by command, which connectionIdOf already rejects on its own, so the && short-circuits before reaching the conjunct at tool-registry.ts:550. A subagent override that keeps the parent's transport but narrows the projection — parent {github: {command: 'gh-mcp'}} against child {github: {command: 'gh-mcp', includeTools: ['search']}}, or child trust: false against parent trust: true — produces identical connectionIdOf values, because fingerprint() deliberately excludes includeTools, excludeTools, trust, alwaysLoadTools (mcp-pool-key.ts:117-120: "Excluded fields (per-session filter / metadata; do NOT change the underlying transport)"). Only this conjunct keeps canInheritMcpServer false there. Delete or "simplify away" it as apparently redundant and refreshMcpTools starts re-registering the parent's full tool set into the narrowed child: the excluded tools become callable again, and with the parent's trust: true projection getDefaultPermission() returns 'allow', so they run in the subagent without confirmation. The gate that is supposed to fail closed on a per-session narrowing regresses to fail open, with no red test.

Witness:

not run as a mutation — the decisive run (drop tool-registry.ts:550, re-run
packages/core vitest, observe all-green) needs an isolated tree and
`review scratch-tree` returned available: false. Settled instead by an exhaustive
static sweep over the COMPLETE reachable population, using the source's own `&&`
short-circuit and fingerprint()'s field list as the oracle (not a reimplementation):
  canInheritMcpServer is private with exactly 3 references — tool-registry.ts:497
    (refreshMcpTools), :1169 and :1183 (readMcpResource)
  grep *.test.ts for refreshMcpTools -> 10 call sites, ALL in
    tool-registry.mcp-recovery.test.ts; readMcpResource adds :61 and :88 in the same file
  every other hit (read-mcp-resource.test.ts, atCommandProcessor.test.ts) MOCKS the registry
  case-by-case enumeration -> 0 of the reachable cases evaluate
    mcpSessionMetadataKey(local) === mcpSessionMetadataKey(inherited) as the deciding conjunct
    (the two override cases at :153 and :175 fail earlier, at connectionIdOf)
  includeTools|excludeTools|alwaysLoadTools|mcpSessionMetadataKey -> NO matches in that file
  agent-core.mcp-recovery.test.ts sets no mcpServers at all
=> deleting :550 leaves the suite green.
// Add a case whose source and child configs SHARE the transport and differ only in
// projection — one variant with includeTools: ['read'] on the child, one with
// trust: false on the child against trust: true on the parent — asserting
// child.refreshMcpTools(...) leaves child.getToolsByServer('server') empty.
const shared = { server: { command: 'same-mcp' } };
const sourceConfig = makeConfig(shared);
const childConfig = makeConfig({ server: { ...shared.server, includeTools: ['read'] } });

The fix must not try to make the transport identity distinguish projections: mcp-pool-key.ts:117-120 states those four fields are excluded on purpose, so two configs sharing a transport must keep sharing a pool entry — the metadata key is the only place the projection may be compared. Also mcp-session-config.ts:44-58: an absent includeTools is null and distinct from an explicit empty list ("allow all" vs "allow none"), so a fixture that omits the field on one side and passes [] on the other is already a divergence.

The new same-transport/different-projection case must assert the child does not inherit; as mutation proof, dropping mcpSessionMetadataKey(local) === mcpSessionMetadataKey(inherited) from canInheritMcpServer (tool-registry.ts:550) must turn that test red while every pre-existing test in the file stays green.

中文说明

[Suggestion] R3-27:新继承门中的 mcpSessionMetadataKey 判定项 —— 对“同传输、不同投影”的覆盖而言是唯一的判别条件 —— 没有任何测试会走到它,因此删掉它整个套件仍为绿,而被排除的工具会在子 agent 中变得可调用且自动获批。本文件中所有“存在差异”的用例都是以 command 区分的,而 connectionIdOf 本身就能拒绝它们,于是 && 在到达 tool-registry.ts:550 的那个判定项之前就短路了。一个保留父级传输、只收窄投影的子 agent 覆盖 —— 父级 {github: {command: 'gh-mcp'}} 对子级 {github: {command: 'gh-mcp', includeTools: ['search']}},或子级 trust: false 对父级 trust: true —— 会产生完全相同connectionIdOf,因为 fingerprint() 有意排除 includeTools, excludeTools, trust, alwaysLoadToolsmcp-pool-key.ts:117-120:“被排除的字段(每会话过滤/元数据;不改变底层传输)”)。只有这个判定项能让 canInheritMcpServer 在此处为 false。把它当作看似冗余而删掉或“简化掉”,refreshMcpTools 就会开始把父级的完整工具集重新注册进被收窄的子级:被排除的工具重新可调用,并且在父级 trust: true 投影下 getDefaultPermission() 返回 'allow',于是它们在子 agent 中无需确认即可运行。本应在“每会话收窄”上失败关闭的门,退化为失败开放,而且没有红色测试。

证据:未以变异方式运行 —— 决定性运行(删除 tool-registry.ts:550、重跑 packages/core vitest、观察全绿)需要隔离树,而 review scratch-tree 返回 available: false。改为对完整可达总体做穷尽静态排查,以源码自身的 && 短路与 fingerprint() 的字段清单作为判定依据(而非重新实现):canInheritMcpServer 是私有的、全树仅 3 处引用(tool-registry.ts:497:1169:1183);检索 *.test.ts 中的 refreshMcpTools 得到 10 处调用点,全部位于 tool-registry.mcp-recovery.test.tsreadMcpResource 在同文件另有 :61 与 :88;其余命中(read-mcp-resource.test.tsatCommandProcessor.test.ts)都是 mock 注册表;逐用例枚举后,可达用例中没有任何一个mcpSessionMetadataKey(local) === mcpSessionMetadataKey(inherited) 作为决定性判定(:153 与 :175 两个覆盖用例更早地在 connectionIdOf 处失败);includeTools|excludeTools|alwaysLoadTools|mcpSessionMetadataKey 在该文件中无匹配agent-core.mcp-recovery.test.ts 完全不设置 mcpServers。结论:删除 :550 后套件仍为绿。

修复方式见上方代码块:新增一个源与子级配置共享传输、仅在投影上不同的用例 —— 一个变体在子级设 includeTools: ['read'],另一个变体子级 trust: false 对父级 trust: true —— 断言 child.refreshMcpTools(...) 之后 child.getToolsByServer('server') 为空。

修复不得试图让传输标识去区分投影:mcp-pool-key.ts:117-120 说明那四个字段是有意排除的,因此共享传输的两个配置必须继续共享同一个池 entry —— 元数据 key 是唯一可以比较投影的地方。另见 mcp-session-config.ts:44-58:缺省的 includeToolsnull,与显式空列表不同(“允许全部”对“全不允许”),所以一侧省略该字段、另一侧传 [] 的 fixture 本身就已构成差异。

新增的“同传输/不同投影”用例必须断言子级不继承;作为变异证明,从 canInheritMcpServertool-registry.ts:550)中去掉 mcpSessionMetadataKey(local) === mcpSessionMetadataKey(inherited) 后,该测试必须变红,而文件中所有既有测试仍为绿。

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

@destire-mio destire-mio changed the title fix(mcp): recover failed pooled connections on subsequent ACP turns fix(mcp): recover failed pooled connections without replaying calls Sep 10, 2026

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at ea4c678. I spent most of this review trying to break the security-relevant claims in the design and could not — the parts I was most worried about are handled correctly, and I want to record that explicitly since it is the useful output here.

What I verified holds:

  • A tool that carries the bootstrap Config cannot spawn a private client. mcp-tool.ts fails closed on !this.reconnectOnError for connection errors — it throws the shared-connection message so the model learns the outcome is unknown, and shouldAttemptReconnect returns false before any client work. reconnectOnError is genuinely populated, not a dead default: session-mcp-view.ts passes false explicitly when cloning a tool for a session.
  • Guarded invocations fail closed on reconnect, with the right reasoning in place — one authorization must not become several execution attempts when a transport error leaves the side effect ambiguous.
  • The per-session tool clone does not bake in the first subscriber's trust or eager-load choice; withSessionConfig takes them per session.
  • The recovery cooldown does not have the "an earlier concurrent success clears a later failure" bug, and the expiry timer checks identity before deleting, so an older failure cannot clear a newer cooldown for the same key.

Two findings, neither Critical.

Route ownership — a process-global read drives a live-session-owner decision. isConnectionError treats getAllMCPServerStatuses().get(this.serverName) === DISCONNECTED as proof that this session's error is a connection error. That status map is keyed by server name only and is process-global, so in the daemon, one session's server going DISCONNECTED will make an unrelated error in another session's tool for a same-named server get reported to the model as "connection error, outcome unknown." Bounded and self-correcting, but it is the wrong scope for the decision — this should consult the status of the connection this tool actually holds.

Cross-session coupling in the recovery cooldown. recoveryRetryAfter is keyed by connectionIdOf(serverName, cfg), which is process-global rather than session-scoped, so a session with a locally broken environment puts every other session's recovery for that config into a 5s cooldown. Mitigated by the fact that explicit discovery/restart goes through the normal acquire path and bypasses the cooldown, which the code notes — so users have an escape hatch. Worth stating the intended scope in the design doc either way.

The main thing I want to raise is scope, and it is a maintainer call rather than a review finding.

This is roughly +1100/−270 production lines across packages/core/src/tools/**, packages/core/src/agents/**, packages/core/src/subagents/**, and packages/cli/src/acp-integration/** — core infrastructure, cross-package, and it introduces two new modules (mcp-pool-entry.ts, mcp-workspace-budget.ts) alongside the recovery change itself. Under our two-tier gate a fix of this size is not size-blocked, but it does escalate for awareness, and I cannot claim to name every downstream consumer of the pool and registry surfaces being changed here with 100% confidence. So: escalating to maintainer review, with the note that the security-critical paths check out.

Concretely, I think this would be easier to land as two changes — the pooling/budget infrastructure first, then the recovery-without-replay behavior on top. Against Simplicity First, a single PR that both restructures pool ownership and changes recovery semantics is hard to review as one unit, and the test additions (+3800 lines) suggest the same.

The design doc is present in both English and Chinese, which is appreciated.

CI: the Test (ubuntu-latest, Node 22.x) lane is failing on this SHA and the branch is BLOCKED. Please get that green before the next round — I did not attempt to attribute the failure, since I could not run tests locally (the worktree lacks node_modules/packages/core/dist, and the vitest globalSetup guard blocks runs until npm run build from the repo root). Everything above is read-verified against ea4c678.

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

3 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • D4-13 recovery-diagnostic dedup narrower than the doc's once-per-turn guarantee (docs/users/features/mcp.md:310) — already recorded on this PR as a round-3 convergence-posture deferral
  • D4-14 stale void/best-effort comment on the now-awaited cleanup barrier (packages/core/src/tools/mcp-pool-entry.ts:411) — already recorded on this PR as a round-3 convergence-posture deferral at mcp-pool-entry.ts:410
  • D4-15 retiringEntries shrink assertion unreachable after drainAll clears the map (packages/core/src/tools/mcp-transport-pool.test.ts:724) — already reported (comment 3966612944)

Unresolved, please confirm:

  • [Critical] R1-23 (mcp-client-manager.ts, runtime-add stdio transportId computed from two different config sources, making stillWanted() never match) — could not be ruled: the claim turns on acpAgent.ts:2637-2650 destructuring cwd out of every runtime-…

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

Not reviewed: build-and-test — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) were skipped in CI, and this diff changes process-teardown code (descendant-pid enumeration, SIGTERM then SIGKILL) whose behaviour is platform-specific; only the linux Test job ran.

Not reviewed: build-and-test — the test-efficacy probe produced no measurement: harnessValidated is null because no probe file was green in the unmutated baseline (the probe runner exited on the vitest globalSetup prerequisite guard), so revert, mutant and per-hunk coverage is unmeasured rather than clean.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": the tail of packages/core/src/tools/tool-registry.mcp-recovery.test.ts (diff lines 6164-6585, ~420 of its 478 lines) falls outside chunk 16, so I walked only …; "agent reverse-audit (round 2)": the EN/ZH claim "The raw runtime overlay remains unstamped so it can follow a workspace relocation" — never traced to the runtime-add stamping code in mcp-clie…; "agent reverse-audit (round 2)": the borrow/override claims "Independent overrides compare effective stdio cwd as well as transport and session policy" and "The legacy command-derived mcp rec…; "agent reverse-audit (round 2)": "Retiring a transport settles the SDK's old pending requests and removes its callbacks before reconnecting; a late child-close event cannot clear the SDK's repl…; "agent reverse-audit (round 2)": did not read docs/developers/tools/mcp-server.md end to end — only the keyword grep above, which returned no matches., and 7 more.

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

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

  • docs/design/2026-09-08-pooled-mcp-recovery.zh-CN.md:9 — [review] D4-1 ZH design doc omits the EN root-cause paragraph and PR #7309's head SHA (carries forward R2-5)
  • packages/core/src/agents/runtime/agent-core.ts:1014 — [review] D4-2 the second cancellation guard (after prepareTools) has no test; deleting it keeps the suite green
  • packages/core/src/tools/mcp-client-manager.ts:3402 — [probe] D4-3 a cancellation or policy denial is re-wrapped as McpServerSpawnFailedError, so clients get errorKind mcp_server_spawn_failed
  • packages/core/src/tools/mcp-workspace-budget.ts:232 — [probe] D4-4 the preserveRefusals/clearRefusal lifecycle falsifies recordRefusal's docstring and five places in the budget-guardrails reference page
  • packages/core/src/tools/tool-registry.mcp-recovery.test.ts:325 — [probe] D4-5 no test drives an aborted signal through the registry's own recovery entry points, so both new cancellation guards are unpinned
  • packages/core/src/tools/mcp-transport-pool.ts:1086 — [probe] D4-6 evictEntry's new seat-index discard loop has no test; without it a long-lived daemon accumulates stale seat mappings without bound
  • packages/core/src/tools/mcp-client-manager.test.ts:347 — [probe] D4-7 two fake pooled connections missed the guard retrofit, making the W94/W108/W112 stop-ordering assertion vacuous
  • packages/core/src/tools/mcp-client-manager.ts:1840 — [probe] D4-8 an unchanged budget refusal re-emits mcp_child_refused_batch on every turn, against the frame's published change-edge contract
  • packages/core/src/tools/mcp-workspace-budget.ts:241 — [probe] D4-9 clearRefusal is wired to one of three pooled connect paths, so a reconnected server keeps being reported as budget-refused
  • packages/core/src/tools/tool-registry.mcp-recovery.test.ts:344 — [probe] D4-10 nothing pins that the read paths scope recovery to one server, so a refactor can silently widen it to a workspace-wide pass
  • packages/core/src/tools/tool-registry.mcp-recovery.test.ts:345 — [probe] D4-11 the new revealedDeferred bookkeeping is unobserved, and the state the loop does restore is unreachable for MCP tools
  • packages/core/src/tools/tool-registry.ts:501 — [probe] D4-12 canInheritMcpServer is recomputed per copied tool on the pre-send path with no memoization

Convergence: round 4 posted 14 inline comment(s), 13 of them reported for the first time; the previous round posted 35 (33 new). Findings keep coming back to the same files: packages/core/src/tools/mcp-client-manager.ts (findings in rounds 1, 2, 3; 5 more now); packages/core/src/tools/mcp-client-manager.test.ts (findings in round 3; 2 more now); packages/core/src/tools/mcp-tool.ts (findings in round 3; 1 more now), and 3 more file(s). 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.)

中文说明

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

本轮确认的 3 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

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

未审查(原文为英文):build-and-test — Test (windows-latest, Node 22.x) and Test (macos-latest, Node 22.x) were skipped in CI, and this diff changes process-teardown code (descendant-pid enumeration, SIGTERM then SIGKILL) whose behaviour is platform-specific; only the linux Test job ran.

未审查(原文为英文):build-and-test — the test-efficacy probe produced no measurement: harnessValidated is null because no probe file was green in the unmutated baseline (the probe runner exited on the vitest globalSetup prerequisite guard), so revert, mutant and per-hunk coverage is unmeasured rather than clean.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"the tail of packages/core/src/tools/tool-registry.mcp-recovery.test.ts (diff lines 6164-6585, ~420 of its 478 lines) falls outside chunk 16, so I walked only …"agent reverse-audit (round 2)"the EN/ZH claim "The raw runtime overlay remains unstamped so it can follow a workspace relocation" — never traced to the runtime-add stamping code in mcp-clie…"agent reverse-audit (round 2)"the borrow/override claims "Independent overrides compare effective stdio cwd as well as transport and session policy" and "The legacy command-derived mcp rec…"agent reverse-audit (round 2)""Retiring a transport settles the SDK's old pending requests and removes its callbacks before reconnecting; a late child-close event cannot clear the SDK's repl…"agent reverse-audit (round 2)"did not read docs/developers/tools/mcp-server.md end to end — only the keyword grep above, which returned no matches.,另有 7 条。

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

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

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

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

Comment thread packages/core/src/tools/mcp-client-manager.ts Outdated
Comment thread packages/core/src/tools/mcp-client-manager.ts Outdated
Comment thread packages/core/src/tools/mcp-tool.ts Outdated

private isConnectionError(error: unknown): boolean {
if (
getAllMCPServerStatuses().get(this.serverName) ===

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R4-2: [certifies-falsely] [new-surface] isConnectionError's status leg reads a process-global registry keyed only by serverName, so the new !this.reconnectOnError && this.isConnectionError(error) branch at :441 can classify an ordinary server-side business error as a shared-connection failure and hide the real error text from the model. The trigger is in-process rather than multi-workspace (each workspace bridge is a separate spawned process, so the map is not shared between workspaces): two pool entries sharing one serverName at different fingerprints — the state the pool's own comments treat as real, e.g. divergent OAuth tokens (mcp-pool-entry.ts:277-285, mcp-pool-key.ts:156-159). McpClient.disconnect() writes the name-keyed DISCONNECTED as its first statement (mcp-client.ts:822), while the sibling-correcting updateGlobalStatus() only runs after await sweepAndDisconnect(reason) (mcp-pool-entry.ts:801-805).

For that whole teardown window the global registry reports DISCONNECTED for a serverName whose sibling entry is healthy and 'active'. A tool call on the healthy entry that fails with an ordinary server error is rewritten into the fixed shared-connection text, and because coreToolScheduler.ts:6022-6024 forwards executionError.message only, cause is dropped — the model is told the connection failed and the call was not replayed, for a call that reached a healthy server and was answered. It cannot correct its arguments because it never sees them being wrong.

Distinct from the fixed R3-32 in this same function: that one was the 404 predicate never matching the installed SDK's error shape, and the widened status check does fix it. This is the status leg, and a different mechanism.

Witness:

[probe] one process, one pool, two stdio entries for 'srv' at different cwd, A's disconnect() gated open:
sibling BEFORE:            global(srv)=connected   clientA=connected clientB=connected
sibling DURING A teardown: global(srv)=disconnected clientB=connected entryB.state=active
sibling END-TO-END during window: global(srv)=disconnected clientB=connected ->
   sameObject=false  modelSees="The shared MCP connection failed. This call was not replayed and its..."
   containsRealError=false      (the real error was "Invalid params: file_path is required")
sibling AFTER A teardown:  global(srv)=connected   clientB=connected
Hunk-revert control: with the :441 branch removed, all four rows return sameObject=true /
   containsRealError=true -- the branch is load-bearing for the wrong certification.

Decide from the invocation's own connection rather than a name-keyed global: prefer this invocation's mcpClient status when present and fall back to the registry only for the callable-tool path that has no direct client. Note the prerequisite — McpDirectClient (:221-240) declares only callTool and optional readResource, so it has no getStatus() today and must be widened (or the owning connection's status carried onto the invocation another way) before this compiles.

mcp-tool.ts:331private readonly mcpClient?: McpDirectClient is optional: tools executing through executeWithCallableTool (the @google/genai mcpToTool path, :838-867) have no direct client, so the name-keyed fallback must stay for that path. Please add a case to the 'does not treat an unrelated business error as a connection loss' family: set the global registry to DISCONNECTED for serverName while the invocation's own client reports CONNECTED, reject callTool with new Error('Invalid params'), and assert rejects.toBe(error) rather than the shared-connection message — it is red today and reddens again if the fix is reverted.

中文说明

[Critical] R4-2:[certifies-falsely] [new-surface] isConnectionError 的状态判断读取的是一个进程级全局注册表,且只以 serverName 为键,因此 :441 新增的 !this.reconnectOnError && this.isConnectionError(error) 分支会把普通的服务端业务错误判定为共享连接失败,并把真实错误文本对模型隐藏。触发条件在进程内部,而不是跨 workspace(每个 workspace bridge 都是独立子进程,该 map 并不跨 workspace 共享):同一 serverName 下存在两个指纹不同的连接池条目——连接池自身的注释就把这种状态视为真实存在,例如 OAuth token 不一致(mcp-pool-entry.ts:277-285mcp-pool-key.ts:156-159)。McpClient.disconnect()第一条语句就写入以名字为键的 DISCONNECTED(mcp-client.ts:822),而修正兄弟条目的 updateGlobalStatus() 要等到 await sweepAndDisconnect(reason) 之后才执行(mcp-pool-entry.ts:801-805)。

在整个拆除窗口内,全局注册表对一个仍有健康 'active' 兄弟条目的 serverName 报告 DISCONNECTED。此时健康条目上一次因普通服务端错误失败的调用会被改写成固定的共享连接文案;由于 coreToolScheduler.ts:6022-6024 只转发 executionError.messagecause 被丢弃——模型被告知连接失败且调用未重放,而实际上该调用到达了一个健康的 server 并得到了应答。模型看不到真正的错误,也就无法修正自己的参数。

与同一函数中已修复的 R3-32 不同:那一条是 404 判定无法匹配已安装 SDK 的错误形状,加宽后的 status 检查确实修好了它;本条针对的是状态这一支,机制不同。

建议:以本次调用自身的连接为准,而不是以名字为键的全局状态——存在时优先读取本次调用的 mcpClient 状态,仅在无直接 client 的 callable-tool 路径回退到注册表。注意前置条件:McpDirectClient:221-240)只声明了 callTool 和可选的 readResource,目前没有 getStatus(),需要先扩展该接口(或用其他方式把所属连接的状态带到 invocation 上)才能编译通过。mcp-tool.ts:331mcpClient 是可选的:经 executeWithCallableTool@google/genaimcpToTool 路径,:838-867)执行的工具没有直接 client,所以该路径必须保留按名字查表的回退。请在“不把无关业务错误当作连接断开”的用例族中补一个用例:把全局注册表设为该 serverName 的 DISCONNECTED,同时让本次调用的 client 报告 CONNECTED,用 new Error('Invalid params') 拒绝 callTool,断言 rejects.toBe(error) 而不是共享连接文案——该用例今天是红的,修复被回退后会再次变红。

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

// override must also match the effective cwd used for its own discovery.
if (
localServers[serverName] &&
localServers[serverName] === sourceServers[serverName]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R4-3: [fails-closed] [regression] This "same recipe keeps the source's ownership" fast path tests object identity, but Config.getMergedMcpServers() re-clones every extension-contributed entry on each call (mcpServers[key] = { ...server, extensionName }, config.ts:6494-6500), so an extension-bundled MCP server can never satisfy it even when both registries read the same Config. It falls through to the fingerprint branch, which stamps each side's own getTargetDir() as cwd (populateMcpServerCommand, mcp-client.ts:1452-1468) and hashes it — and fingerprint deliberately excludes extensionName, so the two clones differ only by that stamp.

A pooled daemon session with an active extension bundling a stdio MCP server (no explicit cwd) launches a subagent with worktree isolation: InProcessBackend.createPerAgentConfigderiveAgentConfig(cwd)rebuildToolRegistryOnOverridecopyDiscoveredToolsFrom. The borrower's target dir differs from the source's, canInheritMcpServer returns false, and refreshMcpTools strips the inherited tools. Nothing restores them — the borrower never discovers (skipDiscovery), and recoverFailedConnections only re-acquires entries already in failedPooledConnections — and readMcpResource refuses too. The subagent silently loses the server for its whole life. Scope, narrowed by probe: settings-declared, topTier and runtime-overlay entries are not affected (their identity survives a second getMcpServers() call and a deriveConfig Object.create chain); same target dir is fine; an explicit cwd on the extension entry is fine. Extension loading in pooled daemon sessions is routine, not exceptional — Config's constructor always builds an ExtensionManager, initialize() refreshes its cache, and the daemon treats extension MCP servers as first-class surface.

Witness:

[probe] two Configs sharing one extension record, /parent vs /child target dirs, one borrowed tool:
PROBE A identity(same settings object shared): true  -> settings-server child tool after refresh: KEPT
PROBE B identity across two getMcpServers() calls: false (deep-equal: true)
        s = {"command":"node","extensionName":"ext"}  c = {"command":"node","extensionName":"ext"}
        -> extension-server child tool after refresh: STRIPPED
PROBE C extension-server SAME dir: KEPT     PROBE D extension-server explicit cwd, differing dirs: KEPT
PROBE E settings entry identity base vs derived: true;  extension entry identity base vs derived: false
PROBE F readMcpResource: rejected 'pool connection unavailable';  routed to source manager: 0, child: 1
FIX ARM (treat two clones of the same extension record as one shared recipe):
        PROBE B KEPT (was STRIPPED);  PROBE F resolved, routed source 1 / child 0;  Tests 97 passed (97)

Do not rely on object identity for the shared-recipe test. Compare the pre-merge source instead — resolve both sides through the same call and compare a canonical key (matching extensionName plus a deep-equal of the raw entry), or have getMergedMcpServers() memoize its clones so identity survives within a call.

tool-registry.mcp-recovery.test.ts:187 pins that two independently declared recipes with differing effective cwd are NOT inheritable (sameDirectory: falseundefined), so the fix must distinguish "two clones of one extension record" from "two separately declared identical recipes". Please add a case that builds both registries from a Config whose mcpServers come from getActiveExtensions() (not a shared literal), gives them different target dirs, and asserts the borrowed tool survives refreshMcpTools and readMcpResource routes to the source manager; it is red today, and the fix arm turns it green while :187 and the whole keeps command-derived MCP ownership block stay green.

中文说明

[Critical] R4-3:[fails-closed] [regression] 这条“同一 recipe 则保留源注册表所有权”的快速路径用的是对象同一性判断,但 Config.getMergedMcpServers() 每次调用都会重新克隆每一个由扩展贡献的条目(mcpServers[key] = { ...server, extensionName }config.ts:6494-6500),因此即使两个注册表读取同一个 Config,扩展内置的 MCP server 也永远无法满足该分支。它会落到指纹分支,而该分支会把各自侧的 getTargetDir() 作为 cwd 打进去(populateMcpServerCommandmcp-client.ts:1452-1468)再哈希——并且 fingerprint 有意排除 extensionName,所以这两个克隆仅仅差在这个 cwd 标记上。

场景:一个启用了扩展(扩展内置 stdio MCP server、未显式配置 cwd)的连接池 daemon 会话,启动一个带 worktree 隔离的 subagent:InProcessBackend.createPerAgentConfigderiveAgentConfig(cwd)rebuildToolRegistryOnOverridecopyDiscoveredToolsFrom。借用方的 target dir 与源不同,canInheritMcpServer 返回 false,refreshMcpTools 便把继承来的工具剥掉。此后没有任何机制恢复:借用方不做发现(skipDiscovery),recoverFailedConnections 只重新获取已在 failedPooledConnections 中的条目,readMcpResource 同样拒绝。该 subagent 在整个生命周期内静默失去这个 server。经探针收窄的范围:settings 声明的、topTier 的以及 runtime overlay 的条目不受影响(它们的对象同一性能在第二次 getMcpServers() 调用和 deriveConfigObject.create 链中保持);target dir 相同没问题;扩展条目显式写了 cwd 也没问题。连接池 daemon 会话中加载扩展是常态而非例外——Config 构造函数总会创建 ExtensionManagerinitialize() 会刷新其缓存,daemon 也把扩展 MCP server 当作一等接口。

建议:不要用对象同一性来判断“共享 recipe”。改为比较合并前的来源——两侧通过同一次调用解析后比较一个规范化键(extensionName 相同加上原始条目的 deep-equal),或让 getMergedMcpServers() 缓存其克隆结果,使同一性在一次调用内得以保持。

tool-registry.mcp-recovery.test.ts:187 钉住了“两个独立声明、有效 cwd 不同的 recipe 不可继承”(sameDirectory: falseundefined),所以修复必须区分“同一条扩展记录的两个克隆”与“两个分别声明但内容相同的 recipe”。请补一个用例:让两个注册表都基于 mcpServers 来自 getActiveExtensions()(而不是共享字面量)的 Config,给它们不同的 target dir,断言借用的工具在 refreshMcpTools 后仍然存在、且 readMcpResource 路由到源 manager;该用例今天是红的,应用上述修复后变绿,同时 :187 与整个 keeps command-derived MCP ownership 用例块保持绿。

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

await expect(
f.manager.readResource('srv', 'test://resource'),
).resolves.toEqual({ contents: [] });
expect(f.replacement.client.callTool).not.toHaveBeenCalled();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-4: This is the only assertion in the suite backing the PR's headline "never replays an interrupted tool call" guarantee, and it cannot fail: nothing reachable from McpClientManager ever calls conn.client.callTool. The manager touches a pooled connection's client only through getStatus() (:719, 1738, 1869, 2889, 3293, 3428) and readResource() (:2904); the only callTool caller in packages/core/src/tools is DiscoveredMCPTool (mcp-tool.ts:653), and this fixture's toolRegistry is a mock that never constructs one. So if a future recovery path did re-issue a cancelled call through the recovered entry, this test would stay green while its assertion reads as the guard for the invariant the PR advertises. The cost is false confidence in the suite's headline guarantee, not a crash.

Witness:

grep -rn "\.callTool(" --include=*.ts packages/core/src packages/cli/src | grep -v "\.test\.ts"
  -> mcp-tool.ts:653, mcp-tool.ts:835, cli/src/serve/routes/a2ui-action.ts:234
grep -n "pooled\.client\.|conn\.client\.|existingConn\.client\." packages/core/src/tools/mcp-client-manager.ts
  -> 719, 1738, 1869, 2889, 2904, 3293, 3428   (none of them callTool)

Either drop the line, or make it falsifiable by asserting on a path that could actually replay — e.g. that recovery only re-registers the session projection (expect(f.toolRegistry.removeMcpToolsByServer).not.toHaveBeenCalled()) plus the recovery notice text returned by recoverFailedConnections. mcp-tool.test.ts:972-1023 already pins no-replay behaviourally, so the invariant is not unguarded repo-wide.

The fix must not require the manager itself to invoke a tool — mcp-client-manager.ts:1813 documents the contract as "Restore session registrations before a new model send; never call a tool." If the assertion is replaced rather than deleted, the witness is the new assertion going red when a recovery path invokes a tool or re-purges the session registry.

中文说明

[Suggestion] R4-4:这是整个测试文件中唯一支撑本 PR 核心承诺“绝不重放被中断的工具调用”的断言,而它不可能失败:从 McpClientManager 可达的代码里没有任何地方会调用 conn.client.callTool。该 manager 只通过 getStatus():719, 1738, 1869, 2889, 3293, 3428)和 readResource():2904)接触连接池句柄的 client;packages/core/src/tools 中唯一的 callTool 调用方是 DiscoveredMCPToolmcp-tool.ts:653),而本 fixture 的 toolRegistry 是一个从不构造它的 mock。因此,如果将来某个恢复路径真的通过恢复出的条目重新发起了被取消的调用,这个测试仍会是绿的,而它的断言看起来却像是该不变量的守卫。代价是对测试套件核心保证的虚假信心,而不是崩溃。

建议:要么删掉这一行,要么让它可失败——断言一个真正可能重放的路径,例如恢复流程只重新注册会话投影(expect(f.toolRegistry.removeMcpToolsByServer).not.toHaveBeenCalled()),再加上 recoverFailedConnections 返回的提示文案。mcp-tool.test.ts:972-1023 已经从行为上钉住了“不重放”,所以该不变量在仓库层面并非无守卫。

修复不得要求 manager 自身去调用工具——mcp-client-manager.ts:1813 把契约写为“在新的模型发送前恢复会话注册;绝不调用工具”。如果是替换而非删除该断言,其验收标准是:当某个恢复路径调用了工具或重新清理了会话注册表时,新断言变红。

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

// The pool owns holder/drain bookkeeping; this boundary stub lets
// the manager cleanup exercise both occupied and unused names.
releaseUnusedBudgetReservation: (name: string) => {
if (name === 'srv' && holders.size === 0) budget.release(name);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-9: This matrix is the only manager-level pin of the swap this PR makes — budget.release(name)pool.releaseUnusedBudgetReservation(name) at both the add-rollback and remove sites — but its boundary stub models release() as dropping the name holder synchronously, while the real McpTransportPool.release never deletes the entry at the last detach: it calls entry.startDrainTimer(this.opts.drainDelayMs) (mcp-transport-pool.ts:590-597, default 30 000) and hasNameSibling iterates this.entries with no state filter (:200-211), with evictEntry's delete the only removal on the drain path. So releaseUnusedBudgetReservation is a no-op for the whole drain window.

The no-sibling rows therefore assert an immediate slot release production does not honour: a maintainer investigating "budget exhausted right after I removed a runtime MCP server" reads a passing test stating the slot is freed on remove, and looks nowhere near the drain window that actually holds it. Conversely the anotherHolder: true rows cannot fail on the delegation at all (their stub branch skips the release whatever the manager does), so nothing in the matrix distinguishes "manager delegated to the pool" from "manager did nothing" on the sibling-preserving side. The two new pool-level tests cover an active sibling (:2466-2492) and no entry at all (:2494-2517); neither pins the draining case, which is the case this swap actually creates.

Witness:

[probe] real McpTransportPool + real WorkspaceMcpBudget({clientBudget:1, mode:'enforce'}) + real
McpClientManager, production default drainDelayMs (not set), fake timers:
{"reservedAfterAdd":["srv"],
 "reservedImmediatelyAfterRemove":["srv"],   <- the test's no-sibling row asserts [] here
 "poolEntriesStillHeld":["srv:draining"],
 "addOther":"refused: Adding 'other' would exceed workspace MCP budget",
 "reservedAfterDrainWindow":[], "poolEntriesAfterDrain":[], "nextNameReservationAfterDrain":"reserved"}
The slot is deferred, not leaked -- so this is a test-fidelity gap, and the deferred behaviour is the
diff's documented intent ("Removal does not promise instant capacity before process retirement").

Model the drain in the stub instead of collapsing it: keep the holder on release() (move 'runtime-entry' into a draining set the hasNameSibling-equivalent still counts), assert getReservedSlots() is ['srv'] immediately after removeRuntimeMcpServer, then add an explicit drain-expiry step that evicts the holder and re-runs the pool-side release, asserting [] only after it. Keep the anotherHolder: true rows as they are — their sibling is faithful.

Each of the four rows must go red when this.pool!.releaseUnusedBudgetReservation(name) is deleted — from removeRuntimeMcpServer (mcp-client-manager.ts:3587) for the remove rows and from the spawn-failure catch (:3473) for the failed-add rows. Today deleting :3587 reddens only the anotherHolder: false row, and only via the stub's own release rule. The corrected stub must not treat release() as removing the entry from the sibling set: entry.startDrainTimer(this.opts.drainDelayMs) (:596) leaves it in this.entries, and evictEntry's this.entries.delete(id) (:1083) is the sole deletion on the drain path.

中文说明

[Suggestion] R4-9:这组参数化用例是 manager 层面上唯一钉住本 PR 那次替换的测试——在添加回滚与移除两处把 budget.release(name) 换成 pool.releaseUnusedBudgetReservation(name)——但它的边界 stub 把 release() 建模为同步移除名字持有者,而真实的 McpTransportPool.release 在最后一个席位 detach 时并不会删除条目:它调用 entry.startDrainTimer(this.opts.drainDelayMs)mcp-transport-pool.ts:590-597,默认 30 000),而 hasNameSibling 遍历 this.entries 时不做任何状态过滤(:200-211),驱逐路径上唯一的删除是 evictEntry 里的 delete。因此在整个 drain 窗口内 releaseUnusedBudgetReservation 都是空操作。

于是“无兄弟条目”那几行断言了一个生产环境并不履行的即时释放:调查“刚移除一个 runtime MCP server 就预算耗尽”的维护者,会看到一个通过的测试声称移除时槽位即被释放,而完全不会去看真正持有它的 drain 窗口。反过来,anotherHolder: true 那几行在“是否委派”这件事上根本不可能失败(无论 manager 做什么,它们的 stub 分支都会跳过释放),所以整个矩阵在保留兄弟条目这一侧无法区分“manager 委派给了连接池”与“manager 什么都没做”。新增的两个连接池层测试覆盖的是存活兄弟条目(:2466-2492)和完全无条目:2494-2517);两者都没有钉住 draining 这一情形,而这恰恰是本次替换所创造的情形。

建议在 stub 中建模 drain 而不是把它压缩掉:release() 时保留持有者(把 'runtime-entry' 移入一个 draining 集合,让等价于 hasNameSibling 的逻辑仍然计入它),断言 removeRuntimeMcpServer 之后 getReservedSlots() 立即为 ['srv'],然后加一个显式的 drain 到期步骤来驱逐持有者并重新执行连接池侧的释放,只在其之后断言 []anotherHolder: true 那几行保持原样——它们的兄弟条目是真实的。

四行用例在删除 this.pool!.releaseUnusedBudgetReservation(name) 时都必须变红——remove 行对应 removeRuntimeMcpServermcp-client-manager.ts:3587),failed-add 行对应 spawn 失败的 catch(:3473)。今天删除 :3587 只会让 anotherHolder: false 那一行变红,而且只是通过 stub 自己的释放规则。修正后的 stub 不能把 release() 当作从兄弟集合中移除条目:entry.startDrainTimer(this.opts.drainDelayMs):596)会让它留在 this.entries 中,而 evictEntrythis.entries.delete(id):1083)是 drain 路径上唯一的删除。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R4-9: The new budget matrix stubs the pool boundary so that release() drops the holder synchronously, but the real pool keeps the just-released entry in entries for drainDelayMs — so the assertion this test makes about the post-remove budget state (getReservedSlots() === [], tryReserve('next-server') === 'reserved') is unreachable in production, and the remove-path release this diff introduced (mcp-client-manager.ts:3616-3620, replacing an unconditional budget.release(name)) never fires.

Daemon/pool mode, operator-set budget (clientBudget: 1, mode: 'enforce'), one stdio runtime server srv added. Operator removes srv and immediately adds other. removeRuntimeMcpServer calls poolConn.release()McpTransportPool.release (mcp-transport-pool.ts:578-596) detaches the seat and, at refs.size === 0 for a pooled transport (stdio is in POOLED_TRANSPORTS_DEFAULT, mcp-pool-key.ts:38-40), calls entry.startDrainTimer(this.opts.drainDelayMs) — default 30_000 (mcp-transport-pool.ts:157) — leaving the entry in this.entries. The next statement, this.pool!.releaseUnusedBudgetReservation(name), runs hasNameSibling(name) (mcp-transport-pool.ts:173-177, 199-208), which finds that still-present draining entry and returns true, so budget.release is not called. addRuntimeMcpServer('other') then hits budget.tryReserve('other')refused (reservedSlots.size >= clientBudget) → McpBudgetWouldExceedError: the add is rejected for up to 30 s after the removal, where the pre-diff budget.release(name) freed the slot immediately. The slot is only freed later by evictEntry (mcp-transport-pool.ts:1083-1094), which deletes the entry before

Decide the contract and make both sides agree. If the slot must be freed on removal: have releaseUnusedBudgetReservation (or a new releaseReservationAfterLastHolder) ignore entries with refs.size === 0 that are draining — i.e. count only entries that are live or spawning, matching the documented contract — or have removeRuntimeMcpServer evict the entry it just released before asking for the reservation back. If the hold-until-drain is deliberate: change this test's stub to model it (release marks a draining holder that only an eviction step clears) and change the post-remove assertions to ['srv'] / 'refused', and say so where the budget behaviour is documented.

Witness:

`P8 after handle.release reserved = [ 'srv' ] … entryState = [ 'draining' ]` / `P8 after releaseUnusedBudgetReservation reserved = [ 'srv' ]` / `P9 add "other" immediately after remove => McpBudgetWouldExceedError` / `P9 add "other" 30s later => added:"other"` — against the test's asserted `toEqual([])` / `'reserved'` at `mcp-client-manager.test.ts:5111-5115` (probe: `/tmp/vrfy-3e2d8f457451/tree/packages/core/src/tools/probe-a39.test.ts`, real pool, unmodified PR code).

The fix must not violate this existing fact: hasNameSibling's documented rule — "Check whether any pool entry (live OR currently spawning) shares the given serverName. … slot ownership is per-NAME, so the slot stays reserved as long as at least one entry / spawn for the name exists" (packages/core/src/tools/mcp-transport-pool.ts:181-186) — and the 'already_held' → "sibling holds it; never release here (the sibling's own onClosed / evictEntry will handle it)" rule (mcp-transport-pool.ts:1029-1035): a fix must not phantom-release a slot a live sibling session or an in-flight spawnInFlight key still holds, and must not bypass evictEntry's identity check (mcp-transport-pool.ts:1081-1083). Acceptance criterion — remove the fix and this must go red: packages/core/src/tools/mcp-transport-pool.test.ts, describe('workspace budget integration') — add the missing third case: acquire('srv', …) for one session, call the returned handle's release(), then pool.releaseUnusedBudgetReservation('srv') and assert the slot state the manager's remove path depends on. Against the pool as it stands this test goes red (slot still ['srv']), which is the mutation witness for the guard change; the manager-side runtime remove preserves only held workspace budget (same-name holder: false) case at mcp-client-manager.test.ts:5046+ must stay green o

中文说明

新的预算矩阵测试把池边界打桩成 release() 同步摘除持有者,而真实的池会把刚释放的条目保留在 retiringEntries 中直到清理完成。因此该矩阵所断言的槽位归还时序与生产行为并不一致,测试通过无法证明真实预算归还路径正确。(本条由第 4 轮该线程的 Suggestion 升级而来。)

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

}
const recover = async () => {
if (this.failedPooledConnections.size === 0) return;
while (this.discoveryInFlight || this.runtimeMcpAddInFlight) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-10: This new serialization loop has no bound of its own, and it sits on the pre-send critical path of every model send (Session.ts:7769, agent-core.ts:1006). The code documents the hazard itself at :1979-1981 — "individual acquires are bounded by runWithTimeout (stdio default 30s, remote 5s), but the aggregate discoveryInFlight promise has no inherent cap" — which is exactly why stop() races the identical promise against SHUTDOWN_DISCOVERY_GRACE_MS = 5_000. Cancellation is the only escape here (Promise.race with the abort promise, :1917-1925).

A discovery or runtime-add pass that does not settle — a stalled transport close, a hung spawn inside the aggregate promise — blocks every subsequent model send in that session indefinitely: no error, no notice, no cooldown, and the turn hangs until the user cancels. The shutdown path bounds this same wait; the pre-send path does not.

Witness:

[probe] real manager, seeded failedPooledConnections candidate, discovery pass parked on a gate that
does not settle:
INTACT PR: {"outcomeAt5500ms":"pending","elapsedMs":5501}  <- still waiting past stop()'s own 5s cap
FIX ARM (race the loop's await against a 5s timer, then break): {"outcomeAt5500ms":"settled","elapsedMs":5001}

One sub-claim of the original report did not reproduce and is not asked for here: that a queued pass multiplies into N × 30s. Measured with three concurrent identical discoverAllMcpTools requests against a 300 ms acquire, {"oneRequest":{"elapsedMs":301,"acquireCalls":1},"threeConcurrentRequests":{"elapsedMs":301,"acquireCalls":1}}reconcile() does chain a fresh pass per request, but a repeated pass over an unchanged, already-seated handle skips pool.acquire, so the chained passes are near-free in that shape.

Bound the loop the way stop() does: race the awaited promise against a grace timer and break out, proceeding with whatever candidates are already seated, so a stalled pass degrades to "no recovery this turn" instead of a hung send. Any bound added here must not shorten the per-acquire runWithTimeout budgets the comment at :1979-1982 names as the only existing limits.

Please add a case to mcp-client-manager.test.ts that parks discoveryInFlight on a never-settling promise, advances fake timers past the bound, and asserts recoverFailedConnections resolves (returning []) rather than staying pending; removing the bound reddens it.

中文说明

[Suggestion] R4-10:这个新增的串行化循环自身没有上界,而它位于每次模型发送前的关键路径上(Session.ts:7769agent-core.ts:1006)。代码在 :1979-1981 自己就写明了这个风险——“单个 acquire 由 runWithTimeout 限定(stdio 默认 30s,远端 5s),但聚合的 discoveryInFlight promise 没有固有上限”——这也正是 stop() 要用 SHUTDOWN_DISCOVERY_GRACE_MS = 5_000 与同一个 promise 竞速的原因。这里唯一的出路是取消(与 abort promise 的 Promise.race:1917-1925)。

一个不会 settle 的发现或 runtime-add 流程——卡住的 transport close、聚合 promise 内部挂起的 spawn——会让该会话后续每一次模型发送都无限期阻塞:没有错误、没有提示、没有冷却,这一轮就这样挂着直到用户取消。关闭路径为同一个等待设了上界,发送前路径没有。

原始报告中有一条子结论未能复现,此处也不作要求:即排队流程会放大成 N × 30s。用三个并发的相同 discoverAllMcpTools 请求对 300ms 的 acquire 实测得到 {"oneRequest":{"elapsedMs":301,"acquireCalls":1},"threeConcurrentRequests":{"elapsedMs":301,"acquireCalls":1}}——reconcile() 确实会为每个请求串接一次新流程,但对一个 recipe 未变、席位已就绪的句柄重复执行时会跳过 pool.acquire,所以在这种形态下串接的流程几乎无成本。

建议按 stop() 的方式给循环设上界:让被等待的 promise 与一个宽限计时器竞速并跳出,用已经就位的候选项继续,使卡住的流程降级为“本轮不恢复”而不是发送挂起。此处新增的上界不得缩短 :1979-1982 注释中作为现有唯一限制的各次 acquire 的 runWithTimeout 预算。

请在 mcp-client-manager.test.ts 中补一个用例:把 discoveryInFlight 挂在一个永不 settle 的 promise 上,推进假定时器越过上界,断言 recoverFailedConnections 会 resolve(返回 [])而不是保持 pending;移除上界时该用例变红。

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

if (!pooled) {
const signal = options?.signal ?? new AbortController().signal;
await this.recoverFailedConnections(signal, {
consumeNotices: false,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-11: readResource's only wait hook is recoverFailedConnections(signal, { consumeNotices: false, serverName }), whose recover() begins if (this.failedPooledConnections.size === 0) return; (:1822) — before the discoveryInFlight/runtimeMcpAddInFlight loop at :1823. So with no recorded failure the call returns immediately and this gate throws while a pass that is about to install the handle is still running.

A resource read (ReadMcpResource tool call, or an @server:uri mention) issued during initial discovery, or during any re-discovery after a config change — the pass's own release loop drops the tracked handle when the fingerprint changed or the entry went terminal (:1643-1661) — fails with "pool connection unavailable; use MCP management to connect it" even though the connection is seconds from being seated. The message is also misleading: it tells the operator to use MCP management for a healthy, already-configured server.

Witness:

[probe] real manager, discovery pass parked mid-pool.acquire, read raced against a 50ms timer:
INTACT PR: {"stillInFlightAtRace":true, "failedCandidates":0,
  "readDuringPass":"MCP server 'srv' pool connection unavailable; use MCP management to connect it.",
  "readAfterPass":{"contents":["payload"]}}
FIX ARM (wait for discoveryInFlight ?? runtimeMcpAddInFlight inside readResource's !pooled branch,
  then re-read): {"readDuringPass":{"contents":["payload"]}, "readAfterPass":{"contents":["payload"]}}
  -- 232 tests green across the three affected suites.
BASE (static, from the merge-base blob): grep -c 'pool connection unavailable' on
  git show b3d022b3:...mcp-client-manager.ts -> 0; a handle-less pooled read fell through to the
  lazy-spawn branch and returned the resource.

Wait for the in-flight pass inside this !pooled branch, then re-read pooledConnections before throwing. Do not hoist the wait above recoverFailedConnections' size === 0 early return — that would put the unbounded wait reported separately at :1823 on the pre-send path of every model send; whichever variant is chosen needs its own bound. The removal of the lazy-spawn fallback itself is intended and pinned ("a missing handle never creates a private standalone client in pool mode"), so the ask here is the missing wait, not the gate.

The added wait must carry its own bound — :1979-1981 records that the aggregate discoveryInFlight promise has no inherent cap, which is why stop() races it against SHUTDOWN_DISCOVERY_GRACE_MS = 5_000. Please add a pool-mode case to mcp-client-manager.test.ts that parks discoveryInFlight on a promise which then populates pooledConnections with a CONNECTED handle whose transportId matches connectionIdOf('srv', cfg), and asserts manager.readResource('srv', 'test://resource') resolves with the resource; today it rejects.

中文说明

[Suggestion] R4-11:readResource 唯一的等待入口是 recoverFailedConnections(signal, { consumeNotices: false, serverName }),而它的 recover()if (this.failedPooledConnections.size === 0) return;:1822)开头——在 :1823discoveryInFlight/runtimeMcpAddInFlight 循环之前。因此当没有记录到失败时,该调用会立即返回,而这个判断就会在一个即将装好句柄的流程仍在运行时抛错。

在初始发现期间,或在配置变更后的任何重新发现期间(该流程自己的释放循环会在指纹变化或条目进入终态时丢弃已跟踪的句柄,:1643-1661),发起的资源读取(ReadMcpResource 工具调用或 @server:uri 引用)会得到 “pool connection unavailable; use MCP management to connect it”,尽管连接几秒后就会就位。这条消息本身也有误导性:它让运维去对一个健康且已配置好的 server 使用 MCP 管理操作。

建议在这个 !pooled 分支内等待进行中的流程,然后重新读取 pooledConnections 再决定是否抛错。不要把等待上移到 recoverFailedConnectionssize === 0 提前返回之前——那会把另行报告的 :1823 无上界等待放到每次模型发送前的路径上;无论选择哪种写法都需要自带上界。移除懒启动回退本身是有意为之且已被钉住(“池化模式下句柄缺失绝不创建私有 standalone client”),所以这里要补的是缺失的等待,而不是撤掉这个判断。

新增的等待必须自带上界——:1979-1981 记录了聚合的 discoveryInFlight promise 没有固有上限,这正是 stop() 要与 SHUTDOWN_DISCOVERY_GRACE_MS = 5_000 竞速的原因。请在 mcp-client-manager.test.ts 中补一个池化模式用例:把 discoveryInFlight 挂在一个随后会用 CONNECTED 句柄填充 pooledConnections(其 transportIdconnectionIdOf('srv', cfg) 匹配)的 promise 上,断言 manager.readResource('srv', 'test://resource') 会返回资源;今天它会拒绝。

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

child.resources,
),
]);
expect(nextParent.client).toBe(nextChild.client);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-12: These three assertions are the only post-recovery check in the suite, and none of them can see a seat-leaking second acquire. snapshot.refs counts logical sessions (mcp-transport-pool.ts:753-757 iterates sessionToEntries, by design and per the design doc's "published pool reference counts remain counts of logical sessions"), so it reads 1 whether the recovery acquire re-seats the same registry or mints a new one.

A mutation that drops the second attachPooledSession/indexAttach on the post-spawn recovery path — handing the first caller's handle to every later acquire on the same logical session — leaves all three asserted quantities identical and the entire pool file green. Only entry.refs.size, which the test never reads, changes. A recovery path that leaks or mis-seats registry attachments would ship undetected, and seat leakage is exactly the class this PR's registrySeatId rework exists to make precise.

Witness:

[probe] mutation A/B:
INTACT PR source: two-seat -> snapshot.refs=1 entry.refs.size=2 seats=2 sameClient=true connectCalls=2
MUTANT:           two-seat -> snapshot.refs=1 entry.refs.size=1 seats=2 sameClient=true connectCalls=2
one-seat control:            snapshot.refs=1 entry.refs.size=1 seats=1 connectCalls=2
Under the mutant the whole pool file stays green: Tests 73 passed (73).
Scope note: a BROADER mutation of the same idea (also applied to the fast-path attach, so the child never
gets a seat even at initial acquire) IS caught -- 4 sibling demand-recovery tests redden. The gap is
specific to the recovery path.

Assert the seat count the pool actually keeps: read entry.refs.size (or the pool's sessionToEntries seat set) after the second recovery acquire and assert it is 2 for the two-registry case, alongside the existing shared-client and connect-count assertions.

mcp-transport-pool.ts:753-757 computes refs per logical session on purpose, so entrySummary[].refs cannot carry this assertion — it must read entry.refs or the seat index. The added assertion reddens under the seat-dropping mutant while the three existing assertions stay green, which is the mutation that proves it.

中文说明

[Suggestion] R4-12:这三条断言是测试套件中唯一的恢复后检查,而它们都看不到“第二次 acquire 泄漏席位”这种情况。snapshot.refs 统计的是逻辑会话数(mcp-transport-pool.ts:753-757 遍历 sessionToEntries,这是设计使然,也符合设计文档“对外发布的连接池引用计数仍然是逻辑会话计数”),所以无论恢复时的 acquire 是重新使用同一个注册表席位还是新建一个,它都读作 1。

一个在 spawn 后恢复路径上去掉第二次 attachPooledSession/indexAttach 的变异——把第一个调用者的句柄交给同一逻辑会话之后所有 acquire——会让这三条被断言的量完全不变,整个连接池测试文件仍然是绿的。只有测试从不读取的 entry.refs.size 会变化。一个泄漏席位或错误分配注册表挂接的恢复路径将不被察觉地合入,而席位泄漏恰恰是本 PR 重做 registrySeatId 想要精确处理的那一类问题。

建议断言连接池真正维护的席位数:在第二次恢复 acquire 之后读取 entry.refs.size(或连接池的 sessionToEntries 席位集合),针对双注册表场景断言其为 2,与现有的共享 client 和 connect 次数断言并列。

mcp-transport-pool.ts:753-757 是有意按逻辑会话计算 refs 的,所以 entrySummary[].refs 无法承载这条断言——必须读取 entry.refs 或席位索引。新增断言在“去掉席位”的变异下会变红,而原有三条断言保持绿,这正是证明它有效的变异。

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

contents: [{ uri: 'test://resource', text: 'child session' }],
});
else await expect(reading).rejects.toThrow('pool connection unavailable');
expect(parentRead).not.toHaveBeenCalled();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R4-13: Every ownership assertion in this new file is decided by ownsMcpServer's sticky, server-agnostic discoveryState !== NOT_STARTED disjunct, so the three per-connection disjuncts this PR's ownership fix adds (pooledConnections / failedPooledConnections / clients) are pinned by no test and can be deleted with the suite green. In all three it.each states the child first awaits discoverAllMcpTools(childConfig) (:96), which sets discoveryState to IN_PROGRESS/COMPLETED and never returns it to NOT_STARTED; ownsMcpServer therefore short-circuits true for every server name before the per-connection disjuncts are ever read.

The state those disjuncts exist for is real and reachable in production: the six discoveryState assignments live in discoverAllMcpTools, runDiscoverAllMcpToolsViaPool and discoverAllMcpToolsIncremental, while discoverMcpToolsForServer / discoverMcpToolsForServerInternal contain none — and subagent-manager.ts:1275-1290 drives exactly that per-server path (subagentRegistry.discoverToolsForServer(name)tool-registry.ts:728) on a registry built with skipDiscovery: true for per-agent-frontmatter servers.

Witness:

[probe] mutation -- ownsMcpServer reduced to `return this.discoveryState !== MCPDiscoveryState.NOT_STARTED;`,
the four per-server disjuncts deleted:
BASELINE (unmutated, 17 files): Tests 3 failed | 1817 passed (1820)
MUTANT   (disjuncts deleted):   Tests 3 failed | 1817 passed (1820)   <- identical signature
Population = every test file that can reach ownsMcpServer (one caller in the repo: tool-registry.ts:542):
refreshMcpTools appears in 1 test file, copyDiscoveredToolsFrom in 9; all 10 plus mcp-client-manager*.test.ts
and src/subagents were run. The 3 baseline failures are the environmental HOME-dependent ones, identical
in both arms.

Add a case that reaches canInheritMcpServer with a borrowing registry whose discoveryState is NOT_STARTED — build the child registry with skipDiscovery and drive only discoverToolsForServer(name), the way subagent-manager does — then assert ownership is decided by the per-connection records: a child holding a pooledConnections entry cannot inherit, and one holding only failedPooledConnections can.

discoveryState is only ever assigned IN_PROGRESS or COMPLETED and is never reset to NOT_STARTED (mcp-client-manager.ts:389, assignments at :1096, :1223, :1574, :1780, :2356, :2558), so a witness case that awaits any discoverAllMcpTools* pass is short-circuited by the first disjunct exactly as the existing three are — the new case must avoid that pass. It reddens when the three per-connection disjuncts are deleted from ownsMcpServer (:2146-2148); today no test in the repo does.

中文说明

[Suggestion] R4-13:这个新文件中所有关于所有权的断言,都是由 ownsMcpServer 里那个粘性的、与 server 无关的 discoveryState !== NOT_STARTED 分项决定的,因此本 PR 所有权修复新增的三个按连接判断的分项(pooledConnections / failedPooledConnections / clients)没有任何测试钉住,删掉它们测试套件仍然是绿的。在三个 it.each 状态中,child 都会先 await discoverAllMcpTools(childConfig):96),这会把 discoveryState 置为 IN_PROGRESS/COMPLETED 且永远不会回到 NOT_STARTED;于是 ownsMcpServer每一个 server 名字都短路返回 true,根本读不到那三个按连接判断的分项。

这些分项所针对的状态在生产中是真实且可达的:六处 discoveryState 赋值都在 discoverAllMcpToolsrunDiscoverAllMcpToolsViaPooldiscoverAllMcpToolsIncremental 中,而 discoverMcpToolsForServer / discoverMcpToolsForServerInternal一处都没有——并且 subagent-manager.ts:1275-1290 恰恰在以 skipDiscovery: true 构建的注册表上走这条按 server 的路径(subagentRegistry.discoverToolsForServer(name)tool-registry.ts:728),用于 per-agent frontmatter 的 server。

建议补一个用例,让 canInheritMcpServer 在一个 discoveryStateNOT_STARTED 的借用注册表上被调用——按 subagent-manager 的方式用 skipDiscovery 构建 child 注册表,只驱动 discoverToolsForServer(name)——然后断言所有权由按连接的记录决定:持有 pooledConnections 条目的 child 不能继承,只持有 failedPooledConnections 的可以。

discoveryState 只会被赋值为 IN_PROGRESS 或 COMPLETED,永远不会被重置为 NOT_STARTED(mcp-client-manager.ts:389,赋值点在 :1096, :1223, :1574, :1780, :2356, :2558),所以任何 await 了 discoverAllMcpTools* 流程的用例都会像现有三个一样被第一个分项短路——新用例必须避开该流程。当 ownsMcpServer:2146-2148)中那三个按连接判断的分项被删除时它会变红;目前仓库中没有任何测试会。

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

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

⚠️ Round 5, and the diff has grown 4.8x since this review first measured it (512 → 2443 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

Unresolved, please confirm:

  • [Critical] packages/core/src/tools/tool-registry.ts:559 (R4-3) — the fix round changed the anchored hunk (560-563); this round did not re-derive the ownership object-identity claim, so whether the change closed it could not be determined.
  • [Critical] packages/core/src/tools/mcp-client-manager.lifecycle.test.ts:133 (R4-4) — the anchored test block was rewritten (hunk 114-309); whether the 'never replays' assertion still backs the headline claim could not be determined this round.
  • [Critical] packages/core/src/tools/tool-registry.mcp-recovery.test.ts:110 (R4-13) — the anchored block changed (hunk 62-143); whether the ownership assertions still rest solely on ownsMcpServer stickiness could not be determined.

Not reviewed: test-efficacy — the probe harness reported harnessValidated: null and all 14 probed files inconclusive (the repository's vitest globalSetup build guard fired inside the probe runner), so test effectiveness for this diff is unmeasured rather than demonstrated.

Not reviewed: worktree cleanliness — the residue measurement refused (unreadable includeIf.gitdir git-config includes), so the shared review worktree's cleanliness was never measured.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": whether purgeServerRegistries (prompt/resource cleanup on the replacement, remove and failed-add paths) is pinned by any test elsewhere in the repo — the life…; "agent reverse-audit (round 1)": did not read the implementation behind llmClient.setTools() (no-arg ACP wrapper; packages/core/src/core/llm-chat.ts:5265 is a different, array-taking setTo…; "agent reverse-audit (round 2)": did not verify the design doc's claim "The synthetic mcp.serverCommand name cannot be overwritten by a runtime addition" against the runtime-add path in mcp-…; "agent reverse-audit (round 2)": did not verify "Shutdown includes evicted entries still being cleaned within its existing deadline" against drainAll 's handling of retiringEntries ( mcp-tra…; "agent reverse-audit (round 1)": whether AgentInteractive.start() 's prepareTools() (agent-interactive.ts:136, wrapped only in runWithAgentContext at :116) runs in the same teammate-identi….

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

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

  • docs/design/2026-09-08-pooled-mcp-recovery.md:30 — [review] This sentence is the design record for a change that renames and re-types two fields the unchanged daemon reference docs/developers/daemon/05-mcp-transport-pool.md documents by n…
  • docs/developers/tools/mcp-server.md:42 — [review] This hunk retargets the replay contract to "outside the workspace MCP pool" in the two consumer docs, but leaves docs/design/mcp-safe-replay.md — the repo's authoritative statement of that…
  • docs/users/features/mcp.md:310 — [review] The added sentence promises per-turn uniqueness for identical diagnostics; the code (and this PR's own test) deliberately suppress only *consecutive* identical states, so a byte-identical line can a…
  • packages/core/src/agents/runtime/agent-core.ts:1006 — [review] The pool guard that keeps the new per-round MCP refresh (and its tool-pruning side effect) out of the default non-daemon CLI is load-bearing but has no negative-control test — t…
  • packages/core/src/tools/mcp-client-manager.test.ts:114 — [review] The three PooledConnection fields this hunk adds to the F2 pool-routing stub stop one method short of a usable handle — the stub has no on , so trackPooledConnection thr…
  • packages/core/src/tools/mcp-client-manager.test.ts:671 — [review] In refreshes metadata on a retained unpooled connection without transport churn , the freshly-acquired replacement handle is given its own throwaway release: vi.fn() , whil…
  • packages/core/src/tools/mcp-client-manager.test.ts:5222 — [review] This is the only test for the new post-acquire eligibility guard in discovery ( mcp-client-manager.ts:1725-1736 ), and its action list omits disabled and removed — leavi…
  • packages/core/src/tools/mcp-client-manager.test.ts:5339 — [review] In 'releases a runtime acquire that finishes after removal' the closing toEqual([]) cannot fail whether or not the superseded add left a failedPooledConnections entry …
  • packages/core/src/tools/mcp-client-manager.test.ts:5862 — [review] This block pins two edges of the new recoveryNotices deletion lattice — the pool-discovery reconcile ( mcp-client-manager.ts:1631-1640 , via the removed / disabled cases…
  • packages/core/src/tools/mcp-client-manager.ts:2800 — [review] Both statements added to removeServer are unreachable no-ops: removeServer runs only in non-pool mode, and both maps can only be populated in pool mode.
  • packages/core/src/tools/mcp-client-manager.ts:3533 — [review] The same logical outcome — "this add was invalidated by a newer operation" — reaches the ACP client under two different errorKind s depending only on where the invalidation land…
  • packages/core/src/tools/mcp-transport-pool.test.ts:329 — [review] keeps parent and child registry subscriptions on the same logical session never observes either registry after the recovery, and its one structural assertion cannot see a l…
  • packages/core/src/tools/mcp-transport-pool.test.ts:373 — [review] The only test for handle-targeted release — updates and releases only the registry that owns the handle — pins just the negative half ("only"): it never asserts that the re…
  • packages/core/src/tools/mcp-transport-pool.test.ts:941 — [review] The new cooldown's stated invariant — // An older failure must not clear a newer cooldown for the same key. guarding if (this.recoveryRetryAfter.get(id) === retryAfter) (…
  • packages/core/src/tools/mcp-transport-pool.ts:753 — [review] getSnapshot() was O(entries) with an O(1) entry.refs.size read per entry; the replacement walks the *entire* session→seat→ids reverse index for *every* entry, making one snaps…
  • packages/core/src/tools/tool-registry.mcp-recovery.test.ts:430 — [review] No test asserts that a tool re-inherited after recovery keeps the source registry's permission-deferred marking, so the mirroring the diff adds on the re-add path ( i…
  • packages/core/src/tools/tool-registry.mcp-recovery.test.ts:448 — [review] The only multi-hop registry chain in the suite ( grandchild → child → source ) is exercised through refreshMcpTools alone; nothing ever calls readMcpResource on i…
  • packages/core/src/tools/tool-registry.ts:566 — [review] The new borrower gate re-implements, in a second class, the exact effective-recipe derivation that McpClientManager.getEffectiveMcpServers() documents as the single source of truth —…

Convergence: round 5 posted 8 inline comment(s), 6 of them reported for the first time; the previous round posted 14 (13 new). Findings keep coming back to the same files: packages/core/src/tools/mcp-tool.ts (findings in round 4; 2 more now); packages/core/src/tools/mcp-client-manager.ts (findings in rounds 3, 4; 1 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.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

[Critical] R3-12 [certifies-falsely] [regression] Still standing: the same-fingerprint runtime re-add gate at packages/core/src/tools/mcp-client-manager.ts:3293 is byte-identical to the code round 4 flagged (the fix round did not touch that line), so the measured loss of persisted configuration still fires.

中文说明

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

⚠️ 第 5 轮,且自本审查首次测量以来 diff 已增长 4.8 倍(源码 diff 行数 512 → 2443)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

未决,请确认:共 3 条(原文未翻译,列表见上方英文部分)。

未审查(原文为英文):test-efficacy — the probe harness reported harnessValidated: null and all 14 probed files inconclusive (the repository's vitest globalSetup build guard fired inside the probe runner), so test effectiveness for this diff is unmeasured rather than demonstrated.

未审查(原文为英文):worktree cleanliness — the residue measurement refused (unreadable includeIf.gitdir git-config includes), so the shared review worktree's cleanliness was never measured.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"whether purgeServerRegistries (prompt/resource cleanup on the replacement, remove and failed-add paths) is pinned by any test elsewhere in the repo — the life…"agent reverse-audit (round 1)"did not read the implementation behind llmClient.setTools() (no-arg ACP wrapper; packages/core/src/core/llm-chat.ts:5265 is a different, array-taking setTo…"agent reverse-audit (round 2)"did not verify the design doc's claim "The synthetic mcp.serverCommand name cannot be overwritten by a runtime addition" against the runtime-add path in mcp-…"agent reverse-audit (round 2)"did not verify "Shutdown includes evicted entries still being cleaned within its existing deadline" against drainAll 's handling of retiringEntries ( mcp-tra…"agent reverse-audit (round 1)"whether AgentInteractive.start() 's prepareTools() (agent-interactive.ts:136, wrapped only in runWithAgentContext at :116) runs in the same teammate-identi…

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

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

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

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

[Critical] R3-12 [certifies-falsely] [regression] Still standing: the same-fingerprint runtime re-add gate at packages/core/src/tools/mcp-client-manager.ts:3293 is byte-identical to the code round 4 flagged (the fix round did not touch that line), so the measured loss of persisted configuration still fires.

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

Comment thread packages/core/src/tools/mcp-tool.ts Outdated
Comment on lines +560 to +562
private getConnectionStatus(): MCPServerStatus | undefined {
if (this.mcpClient && 'transport' in this.mcpClient) {
return this.mcpClient.transport

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R4-2: (fix-induced) [certifies-falsely] [regression] The new connection-scoped status short-circuits on client.transport, but the SDK only clears _transport on close, never on error — so for every non-pooled (standalone) MCP client the name-keyed DISCONNECTED status its own McpClient.onerror just wrote is now unreachable, killing the standalone reconnect/repair path and the unknown-outcome warning that docs/users/features/mcp.md:302 (unchanged prose this diff falsifies) still promises.

Interactive qwen, or a daemon started with QWEN_SERVE_NO_MCP_POOL=1 (the configuration this diff's own docs call out), with an HTTP/SSE MCP server. A tool call's POST fails at the network layer. StreamableHTTPClientTransport.send()'s catch runs this.onerror?.(error); throw error; (@modelcontextprotocol/sdk@1.30.0/dist/esm/client/streamableHttp.js:413-416) — onerror without onclose, so Protocol._transport stays assigned while McpClient.client.onerror writes DISCONNECTED to the global map (mcp-client.ts:586-595). Production mcpClient is always the SDK Client (mcp-client.ts:1724 passes this.client), and 'transport' in client is always true because get transport() is a Protocol.prototype getter — so the reconnectOnError ? getAllMCPServerStatuses().get(...) fallback is dead in production. Two wrong outcomes: (a) the opaque network error (TypeError: fetch failed — the syscall is on .cause, so no MCP_CONNECTION_ERROR_PATTERNS match) is rethrown raw: no discoverToolsForServer, no reconnect, and no "result may be unknown … verify the outcome before trying again" warning, so the model is free to re-issue a call whose side effect may already

Keep the connection-scoped read for pooled tools and restore the name-keyed status for standalone ones, where one manager owns one client per name: ts private getConnectionStatus(): MCPServerStatus | undefined { if (this.mcpClient && 'transport' in this.mcpClient) { // The SDK clears `transport` only on close; a transport error without a // close leaves it set, so a standalone client must also consult the // status its own McpClient.onerror wrote. if (!this.mcpClient.transport) return MCPServerStatus.DISCONNECTED; if (!this.reconnectOnError) return MCPServerStatus.CONNECTED; } return this.reconnectOnError ? getAllMCPServerStatuses().get(this.serverName) : undefined; } If the fixer would rather not reintroduce a name-keyed read on the standalone path at all, the alternative is to make the per-connection signal faithful — thread a status accessor from the owning McpClient into `Di

Witness:

probes quoted above — arm A `rediscover:0, outcome:"threw: fetch failed"` vs controls B/C/D `rediscover:1, ensure:1` + the unsafe-replay warning; timeout arms `errorType=execution_timeout` vs `errorType=none`; the five-shape boundary table; and `'transport' in c: true / own prop: false` on a real SDK 1.30.0 `Client`.

The fix must not violate this existing fact: The pooled sibling isolation this diff was for must survive: mcp-tool.test.ts:1028 'does not attribute a sibling disconnect to a healthy shared call (%s)' sets updateMCPServerStatus(serverName, MCPServerStatus.DISCONNECTED), passes { callTool: call, transport: {} } with .withSessionConfig(false, false, false), and asserts rejects.toBe(error) — so the name-keyed fallback must stay gated on reconnectOnError === true. And the fallback is genuinely process-wide, not per-connection: mcp-status.ts:22 is const serverStatuses: Map<string, MCPServerStatus> = new Map();, one name-keyed map shared by every manager in the process, which is the imprecision the diff set out to remove for Acceptance criterion — remove the fix and this must go red: packages/core/src/tools/mcp-tool.test.ts — add a standalone case (no withSessionConfig, so reconnectOnError defaults to true) whose mcpClient is { callTool, transport: {} } (transport still set), with updateMCPServerStatus(serverName, MCPServerStatus.DISCONNECTED) and a TypeError('fetch failed') that matches no MCP_CONNECTION_ERROR_PATTERNS entry, asserting discoverToolsForServer was called with serverName and ensureTool once — the shape of probe A. Every existing reconnect test passes { callTool } with no transport key, so none of them reaches this branch today

中文说明

getConnectionStatus() 只要 client.transport 存在就直接短路返回,但 SDK 仅在连接关闭时清空 _transport,传输错误(onerror)并不清空。因此对非池化(standalone)客户端而言,它自己的 McpClient.onerror 刚写入的名字级 DISCONNECTED 状态永远读不到:既不会触发重连与重新发现,也不会给出「结果可能未知,请先确认再重试」的告警,模型可以自由重发一个副作用可能已生效的调用。

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

Comment on lines +3487 to +3492
// A newer queued add waits for this rollback, but cannot undo a
// disconnect/stop that invalidated this particular request.
if (
canRollback() &&
this.cliConfig.getRuntimeMcpServers()[name] === config
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R5-1: [fails-closed] [regression] The deleted unconditional this.cliConfig.removeRuntimeMcpServer(name) in the spawn-failure catch is now skipped whenever the request was invalidated by disconnectServer(name) — but disconnectServer never removes the runtime overlay, so the failed add's recipe is left permanently configured.

An operator (MCP management dialog, acpAgent.ts:3890, or the daemon restart/management route) issues a runtime add/replace for srv; addRuntimeMcpServerInner writes the overlay at line 3413 and then awaits pool.acquire, a window as wide as the 30 s stdio discovery timeout. The operator then disconnects srv, which deletes the add token and adds srv to every live exclusion set. The acquire rejects (or succeeds and is discarded as superseded at line 3434). The catch runs, canRollback() is false, so neither removeRuntimeMcpServer(name) nor the previousRuntimeConfig restore executes. Result: Config.runtimeMcpServers['srv'] holds a recipe that has no connection, that disconnectServer will never clear (disconnecting again does not help — only removeRuntimeMcpServer does), and that every later full discovery on that Config re-spawns: getEffectiveMcpServers() feeds it straight into desiredIds/acquirePromises in runDiscoverAllMcpToolsViaPool, and full discovery re-runs on each registry build (config.ts:10152 via discoverAllTools(), reached by agent.ts:538 for every subagent launch, and by ToolRegistry.restartMcpServers()), so the server the operator exp

Keep the "a later queued request cannot restore an older request's rollback authority" rule, but do not let this request's own overlay write outlive its invalidation. In disconnectServer, drop the overlay entry this manager's in-flight add wrote — e.g. capture this.cliConfig.getRuntimeMcpServers()[serverName] before runtimeMcpAddTokens.delete(serverName) and, when a token was present, call this.cliConfig.removeRuntimeMcpServer(serverName) (or restore the captured previousRuntimeConfig). Alternatively let the catch roll back the overlay when exclusions.has(name) was set by a disconnect (as opposed to a superseding add), since the disconnect path performs no overlay cleanup of its own.

Witness:

the two probe arms quoted above — `overlayAfterFailedAdd {"srv":"initial"} → nextFullDiscoverySpawned [{"command":"initial"}]` versus `overlayAfterFailedAdd {"srv":"bad"} → nextFullDiscoverySpawned [{"command":"bad"}]`, same tree, same fixture, only the disconnect moved.

The fix must not violate this existing fact: disconnectServer (packages/core/src/tools/mcp-client-manager.ts:2094-2144) performs no Config-overlay mutation today — it only clears runtimeMcpAddTokens, the exclusion sets, failedPooledConnections, recoveryNotices, pooledConnections, clients, the budget slot and the refusal entry; and removeRuntimeMcpServer (line 3566) is the sole caller of this.cliConfig.removeRuntimeMcpServer(name). Any fix must also preserve docs/design/2026-09-08-pooled-mcp-recovery.md: "A later queued request cannot restore an older request's rollback authority." Acceptance criterion — remove the fix and this must go red: packages/core/src/tools/mcp-client-manager.lifecycle.test.ts:250a new queued add cannot restore rollback authority after disconnect. Capture the expected overlay before the replacement add (or assert f.runtime has no srv key) instead of snapshotting it after the disconnect; the test must then go red if the disconnect-side overlay cleanup is removed, while still asserting that the third queued add (next) cannot roll the state back.

中文说明

disconnectServer 使本次请求失效后,spawn 失败的 catch 分支会因 canRollback() 为假而跳过回滚,于是这次失败添加写入的 runtime overlay 配置被永久保留。disconnectServer 自身从不清理 overlay,因此后续每一次全量发现都会重新拉起这个运维已明确断开的服务器,并持续失败。

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

Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated

扩展元数据装饰会在每次读取时创建配置副本。借用关系通过原有配置合并与准入过滤获取原始配置来源;独立覆盖继续核对有效传输与会话策略,不增加配置缓存。工具异常判断优先读取本次调用的 SDK transport。没有直接客户端的池化 callable 根据错误证据判断,不使用按服务名记录的进程全局状态,避免另一条同名连接关闭时掩盖健康连接的业务错误或执行超时。

池投影使用未过滤的发现快照创建会话工具副本,并在测试中检查共享快照仍保留 standalone 行为。五秒冷却记录会过期,即使配置不再被使用;这些计时器只做清理。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R5-3: The zh-CN design does not carry the same statements as the EN design: it converts a sentence about test input into a production claim the code contradicts, and it drops several EN constraints (transport authentication in the pre-recovery check list, the two root causes in the reproduction section, the validation negative controls).

EN line 53 says "Tests use an unprojected discovery snapshot and verify that the shared snapshot keeps standalone behavior"; ZH renders this as "池投影使用未过滤的发现快照创建会话工具副本" — a claim that session projection registers the snapshot unfiltered. The code does the opposite: SessionMcpView.applyTools compiles the session's include/exclude filter and skips every tool it rejects (packages/core/src/tools/session-mcp-view.ts:203-217), and the comment immediately below records the pre-fix bug this filter exists to prevent — "Pre-fix prompts were registered unconditionally — a session restricting tools to a subset still received every prompt the server advertised, AND each prompt's bound invoke closure over the pool's shared Client reached the same server state/credentials as the more-trusted sibling." A Chinese-reading implementer extending projection (e.g. adding a new object class) takes "未过滤" as the design's intent and re-introduces that cross-session exposure. Same-direction gaps: EN item 3 lists "folder trust, disabled-server, approval, configuration fingerprint, transport authentication and per-tool permission checks" while ZH item 3 lists 工作区信任、禁用状态、待审批状态、传输指纹、工具权限 (no a

Restore the EN meaning in the zh-CN file — replace the projection sentence with the test-scoped claim (e.g. "测试使用未经会话投影的发现快照,并验证共享快照仍保留 standalone 行为;池投影会克隆工具副本,并非零分配路径"), add 传输认证 to item 3's check list, add the two root-cause sentences to 复现与责任划分, and align 验证契约's case list and its "不以模型文本或 end_turn 判定" negative control with EN.

Witness:

not run — the closest capability was a probe of `SessionMcpView.applyTools` for the sub-claim that the zh-CN sentence is contradicted by code; that sub-claim I settled by reading `session-mcp-view.ts:198-217` (it filters) and rejected, and the three confirmed gaps are clause-level text comparisons between two files, for which no runnable oracle exists. Deciding evidence is the quoted line pairs in the table above.

The fix must not violate this existing fact: docs/design/README.md (Content alignment): "Both versions must cover the full design. A summary or a link to the other language does not replace a translation." and "Preserve technical identifiers, file paths, commands, configuration keys, protocol fields, example values, and numeric limits. Translate explanatory prose and captions without changing the behavior they describe." AGENTS.md ## Code Review: "A translation gap alone is a Suggestion, not a Critical." Acceptance criterion — remove the fix and this must go red: N/A (docs-only change; no guard, branch or behaviour to pin).

中文说明

中文设计文档与英文版不一致:它把一句关于测试输入的描述写成了生产行为断言(「池投影使用未过滤的发现快照创建会话工具副本」),而代码恰恰相反——SessionMcpView.applyTools 会编译会话的 include/exclude 过滤条件并跳过所有被拒绝的工具。中文版还遗漏了英文版的若干约束:预恢复检查清单中的「传输认证」、复现章节的两条根因、以及验证契约中的反向对照(不以模型文本或 end_turn 判定)。

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

Comment on lines +546 to +548
waitForCleanup(): Promise<void> | undefined {
return this.cleanupInFlight;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R5-4: [fails-closed] [regression] The new cleanup barrier is unbounded and McpTransportPool.acquire now blocks on it before it is allowed to spawn — and because the barrier contains the entry's own eviction, one transport whose close never settles permanently wedges every later acquire for that server, replacing the pre-existing "entry is terminal → evictEntry → spawn fresh" self-heal.

cleanupInFlight is published as Promise.resolve().then(async () => { await this.sweepAndDisconnect(reason); this.updateGlobalStatus(); this.onClosed(this.id); }) (mcp-pool-entry.ts:801-805, and the equivalent silent-drop chain at :417). sweepAndDisconnect awaits await this.client.disconnect(); (mcp-pool-entry.ts:909) with no bound, and McpClient.disconnect() bounds only terminateSession — it then awaits transport.close() and this.client.close() unbounded (mcp-client.ts:863, 877), in a file whose own comment states an unresponsive server "would otherwise hang disconnect() (and every teardown caller) indefinitely" (mcp-client.ts:842-850, the reason TERMINATE_SESSION_TIMEOUT_MS = 2_000 exists at mcp-client.ts:116). Trigger: a stdio/SSE MCP server whose transport close does not settle is drained, silently dropped, or manually disconnected. Then onClosed(this.id) — the only thing that removes the entry from pool.entries — never runs, so getPendingCleanups's this.entries.get(transportId)?.waitForCleanup() (mcp-transport-pool.ts:947) keeps handing back the same unresolved promise, and acquire's new `while (true) { const cleanups = this.getPendingCleanups(...);

Make the barrier guaranteed to settle: wrap the sweep inside both publication sites in the repo's existing bound — await runWithTimeout(this.sweepAndDisconnect(reason), CLEANUP_BARRIER_TIMEOUT_MS, \pool cleanup for ${this.id}`).catch(() => {})— soupdateGlobalStatus()andonClosed(this.id)(hence eviction, hence the barrier's disappearance fromgetPendingCleanups) still run when client.disconnect()hangs. Belt-and-braces: bound the acquire-side wait too, so no future unbounded barrier can wedgeacquire`.

Witness:

probe in the isolated tree — stdio entry driven terminal by the SDK `onerror` (silent drop) with `client.disconnect()` stubbed to a never-settling promise, then `pool.acquire` for the same fingerprint from a second session:

The fix must not violate this existing fact: mcp-transport-pool.ts:283-296 — the loop re-collects getPendingCleanups(...) every iteration and filters only on completedCleanups, which is populated after the await (for (const cleanup of cleanups) completedCleanups.add(cleanup);). A bound implemented as Promise.race([Promise.all(cleanups), timeout]) that then continues the loop without recording the unresolved barrier in completedCleanups (or breaking) converts the hang into a hot infinite loop, because entries.get(transportId)?.waitForCleanup() returns the identical promise object each pass. Bound precedent already in-tree: const TERMINATE_SESSION_TIMEOUT_MS = 2_000; (packages/core/src/tools/mcp-client.ts:116) used with Acceptance criterion — remove the fix and this must go red: New case in packages/core/src/tools/mcp-transport-pool.test.ts (shape of the existing barrier test at :661, expect(entry.waitForCleanup()).toBeDefined();): stub an entry whose client.disconnect() returns a never-settling promise, drive it terminal via forceShutdown('manual') / the drain timer, then assert await pool.acquire(serverName, cfg, sessionId, toolReg, promptReg, resourceReg) resolves within the bound and returns a handle whose transportId matches but whose entry is a different object (fresh spawn). Mutation: remove the bound → the acquire never resolves and the test time

中文说明

新增的清理屏障 cleanupInFlight 是一个没有上界的 Promise,而 McpTransportPool.acquire 现在会先等待它、才允许新建连接,且该屏障内部还包含条目自身的驱逐。因此只要有一个传输的 close() 永不落定(或屏障被 reject 而消费方未容忍),该服务器之后所有 acquire(含恢复路径)都会被永久卡住,取代了原先「条目终结 → evictEntry → 重新 spawn」的自愈行为。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R5-4: [fails-closed] [regression] R5-4 still stands. The belt-and-braces half of the requested fix landed — POOL_ACQUIRE_CLEANUP_TIMEOUT_MS = 5_000 now bounds the acquire-side wait (mcp-transport-pool.ts:70-71, consumed at :294) — but the load-bearing half did not: cleanupInFlight is still published as Promise.resolve().then(async () => { await this.sweepAndDisconnect(reason); this.updateGlobalStatus(); this.onClosed(this.id); }) with no bound inside (mcp-pool-entry.ts:801-805, and the silent-drop twin at :417). Because eviction happens INSIDE the awaited thing, a transport whose client.disconnect() never settles still never reaches onClosed, so the entry is never evicted, getPendingCleanups keeps handing back the same unresolved promise, and every later acquire for that server fails — now as a repeating 5 s rejection plus a 5 s recovery cooldown per attempt rather than an indefinite hang, with the pre-existing 'terminal entry → evictEntry → spawn fresh' self-heal still displaced. The same shape bites below 5 s: a slow-but-normal teardown (a SIGTERM-ignoring stdio child plus a pid sweep in a container without ps) crosses the deadline too, and the acquire then rejects naming a server that is not hung while keeping its entry and its budget slot. Bound the barrier itself as originally asked (await runWithTimeout(this.sweepAndDisconnect(reason), CLEANUP_BARRIER_TIMEOUT_MS, pool cleanup for ${this.id}).catch(() => {}) at both publication sites) so updateGlobalStatus() and onClosed(this.id) still run when disconnect() hangs; and independently make the acquire-side deadline escalate rather than merely reject — on expiry route the still-present terminal entry through this.evictEntry(transportId, this.entries.get(transportId)) before rethrowing.

Witness:

Executed at fb125a154b against a real McpTransportPool with a terminal entry whose waitForCleanup() settles after 6.5s (a model of a slow-but-normal teardown, sized from the repo and SDK constants): "acquire #1 while a NORMAL 6.5s teardown is in flight: REJECTED after 5005ms: Timed out after 5000ms: MCP cleanup for srv. The MCP server may be hung." / "entry still in pool.entries = true | budget slot still reserved = [\"srv\"] | cleanup finished = false" / "acquireForRecovery vs an unfinished teardown: REJECTED after 5005ms" / "cooldown recorded for the fingerprint = true | entry still present = true" / "next acquireForRecovery (retry): REJECTED after 0ms: MCP recovery for srv is cooling down" / "acquire #3 once the same teardown settled: acquire reached spawnEntry (after 0ms)". Ordering confirmed by reading: the barrier loop (mcp-transport-pool.ts:283-303) runs BEFORE the `else if (existing && existing.isTerminated())` self-heal at :363-378, and runWithTimeout rejects out of acquire uncaught, so on deadline the eviction and slot release are never reached.

The fix must not violate this existing fact: Two quoted facts. (1) R5-4's own: mcp-transport-pool.ts:283-296 — the loop re-collects getPendingCleanups(...) every iteration and filters only on completedCleanups, a fresh Set per call, so a barrier that never settles is re-awaited on every iteration. (2) const QUERY_TIMEOUT_MS = 2_000; and const MAX_DESCENDANTS = 256; (packages/core/src/tools/pid-descendants.ts:18,37) bound the sweep the barrier waits for, and the installed SDK v2 stdio close() races the child exit twice at 2000ms before SIGKILL — so any bound meant to cover a NORMAL teardown must exceed those plus the two 2s races; and evictEntry only releases the slot under if (!this.hasNameSibling(entry.serverName)) (mcp-transport-pool.ts:1099-1102), which the escalation must not bypass.

Acceptance criterion: packages/core/src/tools/mcp-transport-pool.test.ts — extend the rewritten test at line 1815 (which already stubs client.close with a never-resolving promise): after the first acquire rejects on the deadline, assert a second acquire for the same id resolves with ClientLib.Client called twice and entries.get(id) !== oldEntry. That goes red if eviction-on-deadline is removed, since today the entry stays in entries for the life of the process. Please prove it by mutation — remove the guard, run that test, and confirm it goes red.

中文说明

R5-4 仍然存在:清理屏障本身依然没有上界,而本轮修复新增的 acquire 侧超时在到期时只是拒绝、并不会驱逐条目,因此一个 close 永不落定的传输仍然会永久拒绝该服务器之后的所有 acquire,并仍然取代了原有的「条目终结 → evictEntry → 重新 spawn」自愈路径。实测:在一次 6.5 秒的正常拆除过程中,acquire 在 5005ms 后被拒绝并提示「服务器可能已挂起」,条目仍在 pool.entries 中、预算配额仍被占用、冷却被记录,重试立即因冷却被拒;只有拆除真正结束后 acquire 才能到达 spawnEntry。请按原建议为屏障本身加上界,并让 acquire 侧超时升级为驱逐而非单纯拒绝。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R5-4: [fails-closed] [regression] The cleanup barrier acquire waits on is published from teardown paths whose promise can hang or reject, and the five-second caller deadline rejects rather than proceeding while deliberately retaining the barrier — so a teardown that never settles makes every later acquire for that fingerprint fail, permanently.

Failure scenario: A transport whose close() never settles (the shape round 5 named, and which this round reproduced with a throwing status listener and with a hung teardown). acquire reaches the barrier loop, runWithTimeout rejects after 5 s with MCP cleanup for 'srv', and nothing deletes retiringEntries / cleanupInFlight on timeout — the comment at mcp-transport-pool.ts:292-293 states the retention is intentional. Every subsequent acquire for that fingerprint hits the same retained barrier and rejects the same way, and the terminal-entry self-heal further down acquire is never reached, so the server and its budget slot are dead for the life of the daemon.

Witness:

[A/B] byte-identical probe in both trees (base arm = merge base `fdb33117b4`; arm proven by `grep -c getPendingCleanups` → 0 at base, present at PR). Rejected-teardown arm (a throwing MCP status listener registered through the public `addMCPStatusChangeListener`, then removed before the acquires, so the wedge outlives the thrower): `PR: b/c/d -> rejected: listener boom (×3) connectCalls= 1 restartByName -> [{"entryIndex":0,"restarted":false,…}]`. Hung-teardown arm: `PR: b/c/d -> rejected: Timed out after 5000ms: MCP cleanup for 'srv'. The MCP server may be hung. (×3, and a 4th …)`, then `b -> rejected: Timed out after 5000ms … c -> rejected: MCP recovery for 'srv' is cooling down`. Base arm recovers; PR arm does not.

Suggested fix: Give the barrier a terminal escape: drop or replace a cleanup promise that has already rejected, so a later acquire can reach the terminal-entry self-heal.

The fix must not violate this existing fact: mcp-transport-pool.ts:292-293 — "A deadline bounds this caller, not the old transport's lifetime. Keep the barrier on timeout so a later acquire cannot spawn over it." The fix must preserve that no-spawn-over-a-live-teardown guarantee, so it needs a terminal escape (drop or replace a barrier whose promise has already rejected) rather than simply proceeding on timeout.

Acceptance criterion: A real-pool case in packages/core/src/tools/mcp-transport-pool.test.ts that wedges one entry's teardown, asserts the first acquire rejects on the deadline, then releases the teardown and asserts a later acquire succeeds and spawns — removing the self-heal must turn it red. Please prove it by mutation - remove the guard, run that test, confirm it goes red.

中文说明

R5-4 仍然存在。acquire 等待的清理屏障由若干 teardown 路径发布,而这些 promise 可能挂起或拒绝;五秒的调用方超时会直接 reject 而不是继续放行,并且刻意保留屏障——因此一次永不结束的 teardown 会让该指纹之后每一次 acquire 都失败,且无法自愈。

失败场景: 某个 transport 的 close() 永不 settle(第 5 轮指出的形态,本轮分别用“抛异常的状态监听器”和“挂起的 teardown”复现)。acquire 进入屏障循环,runWithTimeout 在 5 秒后以 MCP cleanup for 'srv' 拒绝,而超时时没有任何代码删除 retiringEntries / cleanupInFlight——mcp-transport-pool.ts:292-293 的注释说明这种保留是有意为之。此后该指纹的每一次 acquire 都撞上同一个被保留的屏障并以同样方式拒绝,acquire 后面的终止态自愈分支永远到不了,于是该服务器与其预算槽在守护进程余下生命周期内都是死的。

建议修复: 修复必须保留“不得在仍在进行的 teardown 之上 spawn”这一保证(见 mcp-transport-pool.ts:292-293 注释),因此需要一个终止态出口(把已拒绝的屏障丢弃或替换),而不是简单地在超时后继续放行。请补一个真实池用例:卡住某个 entry 的 teardown,断言首次 acquire 因超时被拒;随后释放 teardown,断言后续 acquire 能成功并 spawn——移除该自愈逻辑应使用例变红。

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

Comment on lines +315 to +316
private static readonly SHARED_CONNECTION_ERROR_MESSAGE =
'The shared MCP connection failed. This call was not replayed and its outcome may be unknown. Connection recovery is managed separately from tool execution. Do not retry automatically; verify the outcome before trying again.';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R5-5: [certifies-falsely] The new shared-connection short-circuit (if (!this.reconnectOnError && this.isConnectionError(error)) in handleReconnectOnError, mcp-tool.ts:440-449) fires unconditionally — before canSafelyReplay() and regardless of whether the request was ever sent — so it hands the model a false "outcome may be unknown / do not retry" verdict for calls that provably never left the process.

session-mcp-view.ts:217-221 (this PR, previously a two-argument call) now clones every pooled tool with reconnectOnError = false. When the shared transport has already closed — server restart, pool entry draining, the exact window this PR's session recovery pass is meant to heal — the SDK's Protocol.request rejects locally: if (this.transport === void 0) throw new SdkError(SdkErrorCode.NotConnected, "Not connected") (verified in node_modules/@modelcontextprotocol/client/dist/index.mjs; nothing is written to the wire). That message matches /not connected/i in MCP_CONNECTION_ERROR_PATTERNS (mcp-tool.ts:74) and getConnectionStatus() also returns DISCONNECTED (the SDK's public get transport() is undefined, index-D4xIIEF6.d.mts:2189), so isConnectionError is true twice over and the invocation throws SHARED_CONNECTION_ERROR_MESSAGE. The model is told an operation "may have completed" and instructed not to retry, so it reports a phantom partial side effect to the user and declines the retry that would have succeeded once the session's own recovery pass restored the connection. Secondary consequence of the same ordering: because the branch precedes `canSafelyRepla

Discriminate "never sent" from "lost mid-request". Snapshot const statusAtSend = this.getConnectionStatus() immediately before the callTool await in executeWithDirectClient/executeWithCallableTool and thread it into handleReconnectOnError; when it was already DISCONNECTED at send time (or canSafelyReplay() is true), throw a plain error that says the call was not sent and is safe to retry after the session recovers, keeping SHARED_CONNECTION_ERROR_MESSAGE for the genuinely ambiguous mid-request case only.

Witness:

probe in the isolated tree using the **real** SDK to produce the never-sent rejection, then feeding it through a `withSessionConfig(false, false, false)` tool whose `mcpClient` has `get transport() { return undefined; }`:

The fix must not violate this existing fact: MCP_CONNECTION_ERROR_PATTERNS already contains /not connected/i (packages/core/src/tools/mcp-tool.ts:74), so the locally-thrown SdkError(NotConnected, "Not connected") cannot be separated from a server-side "not connected" prose by message matching — the discriminator has to be the pre-send connection status, not the error text. Acceptance criterion — remove the fix and this must go red: A new case in packages/core/src/tools/mcp-tool.test.ts beside warns about an ambiguous shared call without replaying it (line ~1000): a withSessionConfig(false, false, false) tool whose mcpClient is { callTool, get transport() { return undefined; } } must reject with the not-sent/retry-safe message, and must go red if the pre-send status snapshot is removed (the branch would fall back to SHARED_CONNECTION_ERROR_MESSAGE). The existing ambiguous-case test at line ~1000 uses { callTool: call } with no transport key, so its status is undefined and it stays green.

中文说明

池化工具的新短路分支 if (!this.reconnectOnError && this.isConnectionError(error)) 是无条件触发的——它先于 canSafelyReplay(),也不判断请求是否真的发出过。于是它把 isConnectionError消息模式匹配结果当成面向模型的事实陈述(「共享 MCP 连接已失败……结果可能未知……请勿自动重试」):对那些根本没有离开进程的调用、以及服务器正常应答但错误文本恰好命中模式的 JSON-RPC 错误,都会给出错误结论。

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

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agent-assisted review at fb125a154b51b227371bdcb49c916bcb1602f9d0 — Partial review — coverage gaps; no additional Critical independently confirmed. This is not a C=0/all-clear conclusion.

Current-head reconciliation

  • R3-12 / R5-1: mcp-client-manager.ts:3489-3504 rolls back the tentative overlay by object identity; canRollback() now gates only restoration of recovery authority. The same-fingerprint path preserves an active entry during restart. Current lifecycle tests cover failed replacement plus remove/disconnect and queued-add invalidation; the old stale-overlay report should not be repeated against this implementation.
  • R4-2: mcp-tool.ts:563-575 reads the owning McpClient status callback before legacy name-only fallback, while separately detecting a closed SDK transport. Discovery supplies that callback and projections/clones/retry construction preserve it. This addresses both cross-connection contamination and the later onerror-with-transport-present regression.
  • R4-3: raw recipe lookup via getMcpServers(false) preserves extension object identity through Config and subagent overrides; independent overrides still compare effective transport/session metadata.
  • R5-2: ACP now synchronizes tools and commands on every pooled pre-send, even with no diagnostic notice. R5-5's definitely-not-sent direct-client case now rejects before callTool (mcp-tool.ts:624-636); pooled failures never enter standalone reconnect/replay. JSON-RPC application errors remain distinct from transport loss.
  • R5-4: cleanup is published before terminal notifications, and acquire has a five-second caller deadline (mcp-transport-pool.ts:284-305). Timeout intentionally retains the barrier; it is not evidence the old process exited. The previous unbounded-caller-wait mechanism is changed, not a reason to permit overlapping replacement processes. Selected tests verify no spawn before cleanup, timeout, and later recovery.
  • R2-2's stale-handle mechanism is addressed by expected-handle release plus registry seats; parent/child registries sharing a logical session have separate seats. R3-2's empty refusal event is guarded by pending refusal count. The Chinese design now includes the previously omitted qualifications. Shared cooldown coupling remains explicitly documented; deferred suggestions are not reopened.

Coverage: reviewed the current production changes across invocation, client close/discovery, pool entry/transport lifetime, manager recovery/runtime serialization, registry inheritance/resource ownership, budget, Config/subagents, agent pre-send, and ACP synchronization; sampled current lifecycle/cleanup tests. Existing selected-workspace routes use the selected runtime bridge; session mutations resolve the live session's Config, while legacy workspace fan-out remains a separate existing surface. No new management route is added.

Not fully cleared: the full historical thread inventory was fetched (108 threads, no nested pagination remaining), but earlier R1/R2/R3 discovery/exclusion/resource interleavings and R4-9's budget test-versus-real-pool claim were not exhaustively re-adjudicated. Nor were all changed tests, every existing daemon dispatch/auth consumer, native transport late-close/restart races, all callable-only paths, real OAuth, or Linux/Windows process cleanup. Static safeguards are not an end-to-end security or liveness proof. The roughly 1,544 changed production-source lines warrant large-fix advisory review, not the external-refactor hard block.

Validation: static pinned-source/dataflow and selected-test inspection only; no tests, builds, daemon runs, or PR code executed. Comment only; no approval implied.

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

⚠️ Round 6, and the diff has grown 5.0x since this review first measured it (512 → 2551 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

6 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • pool-reference doc field table (unpooledIds / sessionToEntries / refs) — already reported (round-5 deferral, docs/design/2026-09-08-pooled-mcp-recovery.md:30)
  • one supersession outcome reaching the ACP client under two errorKinds — already reported (round-5 deferral, packages/core/src/tools/mcp-client-manager.ts:3533)
  • seat-ownership test reads the published logical-session count, not seats — already reported as R4-12 (comment 3979876472, packages/core/src/tools/mcp-transport-pool.test.ts:329)
  • re-inherited tool permission-deferred marking untested — already reported (round-5 deferral, packages/core/src/tools/tool-registry.mcp-recovery.test.ts:430)
  • no multi-hop readMcpResource test — already reported (round-5 deferral, packages/core/src/tools/tool-registry.mcp-recovery.test.ts:448)
  • recoveryNotices deletion lattice unpinned — already reported (round-5 deferral, packages/core/src/tools/mcp-client-manager.test.ts:5862)

Unresolved, please confirm:

  • [Critical] R4-2 (packages/core/src/tools/mcp-tool.ts:562) — the thread body was truncated in the recovered context and could not be fetched inside this round's time budget, so whether the connection-scoped status short-circuit still makes the standalo…
  • [Critical] R4-9 (packages/core/src/tools/mcp-client-manager.test.ts:5086) — the thread body was truncated and not fetched in time, so whether the budget matrix still stubs release() as dropping the holder synchronously could not be determined.
  • [Critical] R5-1 (packages/core/src/tools/mcp-client-manager.ts:3492) — the thread body was truncated and not fetched in time; the gated if (stillCurrent()) removeRuntimeMcpServer(name) is still present at HEAD, but whether disconnectServer now remov…
  • [Critical] R5-5 (packages/core/src/tools/mcp-tool.ts:316) — the thread body was truncated and not fetched in time, so whether the shared-connection short-circuit still fires before canSafelyReplay() and for requests never sent could not be determined.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; the unit-test matrix also skipped Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x), though Test (ubuntu-latest, Node 22.x) ran and passed.

Not reviewed: test-efficacy — the probe harness control produced no verdict (harnessValidated: null) and measured 0 mutants, 0 hunks (0 skipped for control, i.e. the budget was not the limit), so test effectiveness for this diff is unmeasured rather than demonstrated.

Not reviewed: worktree cleanliness — the residue measurement refused (unreadable includeIf.gitdir git-config includes pointing at a missing credentials file), so the shared review worktree's cleanliness was never measured; every agent brief disclosed this and the verifiers used hand-built /tmp copies instead of scratch trees.

Not reviewed: verifier-incidental candidate i1 (packages/cli/src/acp-integration/session/Session.ts:10702) — no round remained to verify it after the review time budget stopped the reverse audit.

Not reviewed: verifier-incidental candidate i2 (packages/core/src/tools/mcp-transport-pool.test.ts:222) — no round remained to verify it after the review time budget stopped the reverse audit.

Not reviewed: verifier-incidental candidate i3 (packages/core/src/tools/mcp-client-manager.ts:1563) — no round remained to verify it after the review time budget stopped the reverse audit.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": whether any higher layer gates a runtime MCP add on safe mode or allowedMcpServers before it reaches the manager — the four call sites ( acpAgent.ts:2686 , s…; "agent reverse-audit (round 1)": the EN item-3 claim that recovery preserves "transport authentication" checks — I verified trust, disabled, pending-approval and fingerprint in stillWanted() …; "agent reverse-audit (round 1)": the "Parent and child registries" paragraph's borrowing/ownership rules ( canInheritMcpServer , separate prompt/resource registries for MCP-declaring children, …; "agent reverse-audit (round 1)": "Extension metadata decoration creates a fresh configuration object on each read … This adds no configuration cache" and "Retiring a transport settles the SDK's…; "agent reverse-audit (round 1)": "Reject a discovery snapshot if the connection closed while any listing was in flight" — I read only the already-filed c20 characterization of that guard, not …, and 7 more.

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

Deferred under the convergence posture (round 6, not a blocker) — recorded, not requested in this round; 2 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:

  • packages/core/src/subagents/subagent-manager.ts:1246 (+1 locations) — [review] Critical [fails-closed] [new-surface] The subagent recipe snapshot is frozen at override-build…
  • packages/core/src/tools/mcp-client-manager.ts:3226 (+1 locations) — [review] Critical [fails-closed] [new-surface] Runtime-add eligibility gate omits safe mode and…
  • docs/design/2026-09-08-pooled-mcp-recovery.zh-CN.md:7 (+2 locations) — [review] design zh-CN:7 and :18 — the Chinese design merges two…
  • docs/users/features/mcp.md:310 (+1 locations) — [review] docs/users/features/mcp.md:310 — "There is no background…
  • docs/users/features/mcp.md:310 (+1 locations) — [review] docs/users/features/mcp.md:310 — "Concurrent sessions…
  • packages/cli/src/acp-integration/session/Session.ts:8003 (+1 locations) — [review] Session.ts:8003 — the per-send re-sync gate is…
  • packages/core/src/agents/runtime/agent-core.ts:1006 (+1 locations) — [review] agent-core.ts:1006 — the per-round declaration refresh is…
  • packages/core/src/subagents/subagent-manager.ts:1252 (+1 locations) — [review] subagent-manager.ts:1252 — the new prompt/resource…
  • packages/core/src/tools/mcp-client-manager.lifecycle.test.ts:177 (+1 locations) — [review] mcp-client-manager.lifecycle.test.ts:177 — the only test…
  • packages/core/src/tools/mcp-client-manager.lifecycle.test.ts:405 (+1 locations) — [review] mcp-client-manager.lifecycle.test.ts:405 — the only…
  • packages/core/src/tools/mcp-client-manager.test.ts:4489 (+1 locations) — [review] mcp-client-manager.test.ts:4489 — the new trust/approval…
  • packages/core/src/tools/mcp-client-manager.test.ts:5352 (+1 locations) — [review] mcp-client-manager.test.ts:5352 — the fixture hands the…
  • packages/core/src/tools/mcp-client-manager.test.ts:5573 (+1 locations) — [review] mcp-client-manager.test.ts:5573 — acquireForRecovery…
  • packages/core/src/tools/mcp-client-manager.ts:2888 (+1 locations) — [review] mcp-client-manager.ts:2888 — the new readResource gate…
  • packages/core/src/tools/mcp-pool-entry.ts:787 (+1 locations) — [review] mcp-pool-entry.ts:787 — forceShutdown's new return…
  • packages/core/src/tools/mcp-tool.test.ts:1137 (+1 locations) — [review] mcp-tool.test.ts:1137 — the diff completes the SDK…
  • packages/core/src/tools/mcp-transport-pool.test.ts:1838 (+1 locations) — [review] mcp-transport-pool.test.ts:1838 — deleting the W125 test…
  • packages/core/src/tools/mcp-transport-pool.test.ts:2561 (+1 locations) — [review] mcp-transport-pool.test.ts:2561 — the sibling=true branch…
  • packages/core/src/tools/mcp-transport-pool.ts:1022 (+1 locations) — [review] mcp-transport-pool.ts:1022 — attachPooledSession…
  • packages/core/src/tools/mcp-workspace-budget.ts:289 (+1 locations) — [review] mcp-workspace-budget.ts:289 — flushRefusedBatch…
  • …and 2 more (see the run report)

Convergence: round 6 posted 5 inline comment(s), 4 of them reported for the first time; the previous round posted 8 (6 new). Findings keep coming back to the same files: packages/core/src/tools/mcp-client-manager.ts (findings in rounds 4, 5; 1 more now); packages/core/src/tools/mcp-tool.ts (findings in rounds 4, 5; 1 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.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

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

⚠️ 第 6 轮,且自本审查首次测量以来 diff 已增长 5.0 倍(源码 diff 行数 512 → 2551)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

本轮确认的 6 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未决,请确认:共 4 条(原文未翻译,列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; the unit-test matrix also skipped Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x), though Test (ubuntu-latest, Node 22.x) ran and passed.

未审查(原文为英文):test-efficacy — the probe harness control produced no verdict (harnessValidated: null) and measured 0 mutants, 0 hunks (0 skipped for control, i.e. the budget was not the limit), so test effectiveness for this diff is unmeasured rather than demonstrated.

未审查(原文为英文):worktree cleanliness — the residue measurement refused (unreadable includeIf.gitdir git-config includes pointing at a missing credentials file), so the shared review worktree's cleanliness was never measured; every agent brief disclosed this and the verifiers used hand-built /tmp copies instead of scratch trees.

未审查(原文为英文):verifier-incidental candidate i1 (packages/cli/src/acp-integration/session/Session.ts:10702) — no round remained to verify it after the review time budget stopped the reverse audit.

未审查(原文为英文):verifier-incidental candidate i2 (packages/core/src/tools/mcp-transport-pool.test.ts:222) — no round remained to verify it after the review time budget stopped the reverse audit.

未审查(原文为英文):verifier-incidental candidate i3 (packages/core/src/tools/mcp-client-manager.ts:1563) — no round remained to verify it after the review time budget stopped the reverse audit.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"whether any higher layer gates a runtime MCP add on safe mode or allowedMcpServers before it reaches the manager — the four call sites ( acpAgent.ts:2686 , s…"agent reverse-audit (round 1)"the EN item-3 claim that recovery preserves "transport authentication" checks — I verified trust, disabled, pending-approval and fingerprint in stillWanted() …"agent reverse-audit (round 1)"the "Parent and child registries" paragraph's borrowing/ownership rules ( canInheritMcpServer , separate prompt/resource registries for MCP-declaring children, …"agent reverse-audit (round 1)""Extension metadata decoration creates a fresh configuration object on each read … This adds no configuration cache" and "Retiring a transport settles the SDK's…"agent reverse-audit (round 1)""Reject a discovery snapshot if the connection closed while any listing was in flight" — I read only the already-filed c20 characterization of that guard, not …,另有 7 条。

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

收敛姿态下延后(第 6 轮,非阻断)——已记录,本轮不要求修改;其中 2 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 22 条(原文未翻译,列表见上方英文部分)。

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

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

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

// A JSON-RPC error is a response to this call, even if the transport
// closes afterwards. Its application code 404 is not an HTTP status,
// and its prose may describe a disconnected service behind the server.
if (!this.reconnectOnError && error instanceof ProtocolError) return false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-1: [certifies-falsely] Connection-ness is still decided by probing error field names and matching error prose, and the authoritative-type exclusion this diff adds (error instanceof ProtocolError) is gated on !this.reconnectOnError, so the standalone/legacy path still reads a healthy server's completed JSON-RPC answer as a dead connection. A tool built the way mcp-client.ts:1747 builds it (reconnectOnError true) whose server answers with ProtocolError(404, 'customer record not found' ) has the server's real error discarded and replaced by 'MCP tool execution may have completed before the connection failed … do not retry', plus one spurious full re-discovery; annotated read-only and idempotent in a trusted folder, the already-answered call is executed three more times. The same happens for ProtocolError(-32602, 'not connected to the selected database') and for a plain Error('upstream service disconnected') — ordinary application errors from a database or SSH tool. This PR's own new test asserts the opposite outcome for this exact input, but only for the .withSessionConfig(false,false,false) copy. Close the class rather than extending the field list: make the JSON-RPC exclusion unconditional and structural (if (error instanceof ProtocolError && error.code !== MCP_REQUEST_TIMEOUT_CODE) return false;) and make isMcpDeadSessionHttpError require a transport-level error via the SDK's brand-matched guard (SdkHttpError.isInstance(error) and error.status === MCP_DEAD_SESSION_HTTP_CODE) rather than any object with a numeric code === 404; prose patterns then remain only as a fallback for errors carrying no SDK class.

Witness:

Two independent executed probes at fb125a154b against the built product. LEGACY (reconnectOnError true) with ProtocolError(404,"customer record not found") -> "Error: MCP tool execution may have completed before the connection failed. Automatic replay was skipped...", callTool x1, discoverToolsForServer x1; the identical input through .withSessionConfig(true,false,false) -> "ProtocolError: customer record not found", callTool x1, discover x0. Annotated+trusted legacy replay arm -> callTool x4 (3 replays of an already-answered call). Fix arm (exclusion made unconditional but for MCP_REQUEST_TIMEOUT_CODE) -> legacy returns "ProtocolError: customer record not found", callTool x1, discover x0, while the #9944 control Error("Unknown session",{code:404}) still routes to reconnect and a genuine Error("Connection closed") still reconnects.

The fix must not violate this existing fact: Two constraints, both quoted. (1) The dead-session recovery of issue #9944 must survive: const MCP_DEAD_SESSION_ERROR_PATTERN = /session (not found|terminated|expired)/i; (mcp-tool.ts:65-66) is documented at mcp-tool.ts:53-59 as the single source consumed by TWO decision sites, so a wire ProtocolError carrying -32001 (MCP_REQUEST_TIMEOUT_CODE, mcp-tool.ts:84) with a 'session not found/terminated/expired' message must still classify as a connection error on the legacy path; the existing test 'preserves standalone reconnect for an error without transport close' (mcp-tool.test.ts:1080-1105) pins that legacy reconnect and must stay green. (2) The numeric-code branch cannot simply be deleted: @modelcontextprotocol/sdk: ^1.30.0 is still a dependency (packages/core/package.json:93) and its StreamableHTTPError declares readonly code: number | undefined (node_modules/@modelcontextprotocol/sdk/dist/esm/client/streamableHttp.d.ts:5); conversely the status branch cannot be dropped because v2's SdkError declares readonly code: SdkErrorCode whose members are strings, and only SdkHttpError's get status(): number carries the HTTP status. Use SdkHttpError.isInstance rather than instanceof — the SDK documents it as the brand-matched guard that works across separately bundled copies.

Acceptance criterion: packages/core/src/tools/mcp-tool.test.ts — extend the existing it.each([[-32602,'not connected to the selected database'],[404,'customer record not found']])('preserves a healthy server error with code %s') (~line 1112) to also run the copy built WITHOUT .withSessionConfig(false,false,false) so reconnectOnError keeps its true default, asserting rejects.toBe(error) plus expect(discoverToolsForServer).not.toHaveBeenCalled(). Confirmed red today (gets the UNSAFE_REPLAY message plus one discoverToolsForServer call) and green for the shared copy; reverting the ProtocolError exclusion reddens it. Please prove it by mutation — remove the guard, run that test, and confirm it goes red.

中文说明

连接是否断开的判断仍然依赖探测错误对象的字段名并匹配错误文案;本次新增的权威类型排除(error instanceof ProtocolError)被限定在 !this.reconnectOnError 之下,因此独立/传统路径仍会把健康服务器已经完成的 JSON-RPC 应答当作连接已断开。实测:传统工具收到 ProtocolError(404, "customer record not found") 时,服务器真实的错误文本被丢弃并替换为「调用可能已完成、请勿自动重试」,同时触发一次多余的重新发现;若工具带有 readOnlyHint/idempotentHint 且目录可信,callTool 会被调用 4 次而非 1 次。修复方向是让该排除无条件且结构化(仅豁免 MCP_REQUEST_TIMEOUT_CODE),并让 isMcpDeadSessionHttpError 通过 SDK 的品牌判定守卫要求传输层错误。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-1: [certifies-falsely] Connection-ness is still decided by probing error field names and matching error prose, and the authoritative-type exclusion this diff adds (error instanceof ProtocolError) is gated on !this.reconnectOnError, so the standalone/legacy path still reads a healthy server's completed JSON-RPC application error as a dead connection.

Failure scenario: A non-pooled MCP server answers tools/call with an application-level JSON-RPC error whose code is 404 (e.g. {"error":{"code":404,"message":"customer record not found"}}). Because reconnectOnError is true on the standalone path, the new instanceof ProtocolError exclusion does not apply, the 404 pattern match fires, and the call is reported as an unknown-outcome/possible-dead-connection instead of surfacing the server's actual answer. The operator is told to verify a side effect that never became uncertain, and the application error is discarded.

Witness:

[probe] real `ProtocolError(404, "customer record not found")` driven through a legacy tool (`reconnectOnError` default true, status CONNECTED, `transport: {}`): `PROBE legacy outcome: rejected: MCP tool execution may have completed before the connection failed. … Do not retry automatically; verify the outcome before trying again.` With the candidate fix (`if (error instanceof ProtocolError) return false;`, i.e. the gate removed): `PROBE legacy outcome: rejected: customer record not found`, `discoverToolsForServer= 0 ensureTool= 0` — the application answer survives and no reconnect is attempted.

Suggested fix: Widen the authoritative-type exclusion rather than deleting it, so the legacy path also recognises the real error type; the pooled path depends on this exclusion.

The fix must not violate this existing fact: packages/core/src/tools/mcp-tool.ts:582if (!this.reconnectOnError && error instanceof ProtocolError) return false;. The pooled path depends on this exclusion, so the fix must widen the gate rather than delete it, and SessionMcpView.applyTools (session-mcp-view.ts:217-221) is what forces reconnectOnError=false for pool-created tools.

Acceptance criterion: The two new it.each cases in packages/core/src/tools/mcp-tool.test.ts must be extended with a legacy (reconnectOnError: true) row asserting the application error text is surfaced; removing the instanceof ProtocolError exclusion from the legacy path must turn that row red. Please prove it by mutation - remove the guard, run that test, confirm it goes red.

中文说明

R6-1 仍然存在。“是否为连接错误”依旧靠探测错误字段名与匹配错误文案来判断,而本次 diff 新增的权威类型排除(error instanceof ProtocolError)被 !this.reconnectOnError 限定,因此 standalone/legacy 路径仍会把健康服务器已完成的 JSON-RPC 应用层错误当成连接已断开。

失败场景: 非池化 MCP 服务器对 tools/call 返回应用层 JSON-RPC 错误且 code 为 404(例如 {"error":{"code":404,"message":"customer record not found"}})。由于 standalone 路径上 reconnectOnError 为 true,新增的 instanceof ProtocolError 排除不生效,404 文案匹配命中,该调用被上报为“结果未知/连接可能已断”,而不是把服务器的真实应答交给调用方。运维者被要求去核实一个从未变得不确定的副作用,而应用层错误本身被丢弃。

建议修复: 建议把该排除条件的门槛放宽(而不是删除),使 legacy 路径也能识别权威错误类型;并在 mcp-tool.test.ts 新增的两组 it.each 用例中补一行 legacy(reconnectOnError: true)断言,确保移除该排除后测试变红。

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

Comment on lines +3459 to +3460
this.trackPooledConnection(name, conn);
toolCount = this.toolRegistry.getToolsByServer(name).length;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-2: [certifies-falsely] The runtime-add connect path never calls the new WorkspaceMcpBudget.clearRefusal(name), so a server that is CONNECTED and holding a slot stays in the workspace refusal set and /workspace/mcp reports it as budget_exhausted for the life of the runtime. clearRefusal is wired at exactly one of the three paths that can connect a server (recovery, mcp-client-manager.ts:1887); full discovery clears by reset and runtime add clears nothing, and because this diff also stopped recovery passes from resetting the set, nothing but a full discoverAllMcpToolsViaPool can ever evict a stale refusal — which a runtime add deliberately does not trigger. With --mcp-client-budget=2 in enforce mode: discovery refuses X, the operator removes Y at runtime (freeing a slot), X is re-added at runtime and connects, and the cell still reports status: 'error', errorKind: 'budget_exhausted', disabledReason: 'budget' with the hint to raise the budget, for a server whose mcpStatus is connected and whose tools work. The same stale set also drives the aggregate workspace budget cell (acpAgent.ts:7013-7021buildBudgetCells, helper at :7338-7354), so the workspace-level cell reports budget_exhausted with refusedCount = 1 while liveCount/clientBudget show free capacity. Clear at the point a connection is actually live: add this.pool?.getBudget()?.clearRefusal(name); inside trackPooledConnection (mcp-client-manager.ts:1796), the single chokepoint reached by full discovery, runtime add and recovery — not in tryReserve, which runs pre-spawn and would clear a refusal for a server that is still disconnected.

Witness:

Executed at fb125a154b with a real McpClientManager + real WorkspaceMcpBudget and a fake pool whose budget gate is copied verbatim from mcp-transport-pool.ts:412-422: "after discovery reserved = [\"x\",\"y\"] refused = [\"z\"]" / "after disconnect y reserved = [\"x\"] refused = [\"z\"]" / "runtime add z -> {\"name\":\"z\",\"toolCount\":1}" / "after runtime add z reserved = [\"x\",\"z\"] refused = [\"z\"]" / "z tracked live = true | z client status = connected" / "acpAgent.ts:6846 refusedByBudget = true -> status: error | errorKind: budget_exhausted". Fix arm (clearRefusal added inside trackPooledConnection) -> "after runtime add z reserved = [\"x\",\"z\"] refused = []" / "refusedByBudget = false -> status: live | errorKind: (none)".

The fix must not violate this existing fact: Two quoted facts. (1) packages/cli/src/acp-integration/acpAgent.ts:6846 — const refusedByBudget = refusedSet.has(name); feeding status: effectivelyRefused ? 'error' : this.mcpCellStatus(rawStatus, disabled); membership alone shadows a CONNECTED raw status, so the set must never contain a name that currently holds a slot. (2) The refusal set is keyed per NAME while the pool is keyed per fingerprint — mcp-workspace-budget.ts:31-34: 'Reservation key is server NAME (matches v1 contract; two pool entries that share a name but differ by fingerprint consume ONE slot together, not two'; and the budget instance is ONE-PER-WORKSPACE shared by every session's manager (mcp-workspace-budget.ts:24-26), so a clearRefusal(name) issued by one session's successful add removes the refusal for all sessions of that workspace. That is only sound because the reservation just taken is the same per-name slot the refusal was recorded against — do not narrow the clear to a per-session or per-fingerprint key.

Acceptance criterion: packages/core/src/tools/mcp-client-manager.test.ts — a case (alongside the existing pool-budget tests at :5542 that assert getRefusedServerNames()).toEqual(['unrelated'])) that refuses a server in a full discovery pass, then successfully addRuntimeMcpServer's the same name with a live fake connection, and asserts pool.getBudget()!.getRefusedServerNames() no longer contains it. Removing the added clearRefusal call must turn it red. Note the existing pool test mcp-transport-pool.test.ts:183 ('keeps budget refusals observable and retries as soon as capacity is free') stops one step short: it asserts the retry connects but never asserts the refusal set was cleared. Please prove it by mutation — remove the guard, run that test, and confirm it goes red.

中文说明

运行时新增服务器的连接路径从不调用本次新增的 WorkspaceMcpBudget.clearRefusal(name),因此一个已经 CONNECTED 且占用配额的服务器会一直留在工作区拒绝集合中,/workspace/mcp 会在整个运行时生命周期内把它报成 budget_exhaustedclearRefusal 只接在三条可连接路径中的一条(恢复路径)上;全量发现靠重置清理,而运行时新增什么都不清理,且本次改动后恢复过程也不再重置该集合。实测:运行时新增 z 成功后,reserved 为 [x,z]、refused 仍为 [z],状态单元格报 error / budget_exhausted;在 trackPooledConnection 中补上 clearRefusal 后 refused 变为空。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-2: [certifies-falsely] The runtime-add connect path never calls WorkspaceMcpBudget.clearRefusal(name), so a server that is CONFIRMED connectable by an explicit runtime add keeps its stale budget refusal published.

Failure scenario: A server is refused for budget during a bulk pass and lands in lastRefusedServerNames. The operator then adds it at runtime; addRuntimeMcpServerInner acquires and tracks the connection successfully (mcp-client-manager.ts:3459) but never clears the refusal, so GET /workspace/mcp and the next refused_batch frame still report the server as budget-refused while it is in fact connected and serving tools. The management surface certifies a state the same process has already contradicted.

Witness:

[review] impact sweep over the real tree at `af2769a2a2` (oracle: grep of every production call site, tests excluded) — `clearRefusal` has exactly ONE production caller: `packages/core/src/tools/mcp-client-manager.ts:1887` (inside `recoverFailedConnections`, per recovered server). `grep -rn "clearRefusal" packages/core/src packages/cli/src --include=*.ts | grep -v .test.ts` returns that site plus the definition (`mcp-workspace-budget.ts:241`) and a doc comment (`:229`). The runtime-add path (`:3400-3520`) contains none.

Suggested fix: Call budget?.clearRefusal(name) on the runtime-add connect path after the connection is tracked.

The fix must not violate this existing fact: mcp-workspace-budget.ts:241-248clearRefusal deletes from pendingRefusalNames/pendingRefusalTransports AND recomputes lastRefusedServerNames from lastRefusedTransports.keys(), so the fix must call it after the connection is tracked, not before, or the outermost flushRefusedBatch republishes the name.

Acceptance criterion: A case in packages/core/src/tools/mcp-client-manager.test.ts that records a budget refusal for srv, then successfully runtime-adds srv and asserts budget.getRefusedServerNames() no longer contains it; deleting the new clearRefusal call must turn it red. Please prove it by mutation - remove the guard, run that test, confirm it goes red.

中文说明

R6-2 仍然存在。运行时新增(runtime-add)的连接路径从未调用 WorkspaceMcpBudget.clearRefusal(name),因此一个已被显式运行时新增证明可连接的服务器,其过期的预算拒绝记录仍会继续对外发布。

失败场景: 某服务器在批量过程中因预算被拒绝并进入 lastRefusedServerNames。随后运维者在运行时新增它;addRuntimeMcpServerInner 成功获取并登记连接(mcp-client-manager.ts:3459),但从未清除该拒绝记录,于是 GET /workspace/mcp 与下一个 refused_batch 事件仍把该服务器报告为“预算拒绝”,而它实际上已连接并在提供工具。管理面对外认证了一个同进程已经否定的状态。

建议修复: 修复必须在连接被登记之后调用 clearRefusal(它同时会从 lastRefusedTransports 重算 lastRefusedServerNames),否则最外层 flushRefusedBatch 会把该名字重新发布。请补一个用例:先记录 srv 的预算拒绝,再成功运行时新增 srv,断言 budget.getRefusedServerNames() 不再包含它;删除新增的 clearRefusal 调用应使该用例变红。

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

if (this.bulkPassDepth === 0) {
beginBulkPass(options: { preserveRefusals?: boolean } = {}): void {
// A full discovery reevaluates every server; recovery touches a subset.
if (options.preserveRefusals !== true) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-3: [certifies-falsely] A non-preserve beginBulkPass resets only the published refusal snapshot (lastRefusedServerNames / lastRefusedTransports), not the pendingRefusalNames / pendingRefusalTransports recorded by an enclosing scope, so at the outermost endBulkPass a refusal recorded BEFORE the reset is published AFTER it — over a server the nested full discovery already connected. Two ACP sessions in one workspace share one WorkspaceMcpBudget (bulkPassDepth is per-budget while discoveryInFlight / runtimeMcpAddInFlight are per-manager, so nothing serialises one session's discovery against another's recovery): session B's preserve scope records a refusal for Z, session A's nested full-discovery scope frees a slot and connects Z, and B's flush then republishes lastRefusedServerNames = ['Z'] plus a refused_batch event naming Z as budget_exhausted while Z holds the only slot. The workspace route reports status: 'error' for a connected server until the next full discovery begins. The diff's own new test only covers the reverse order (a refusal recorded inside the nested scope), where it happens to give the right answer. Make the reset own the pending set too, matching the new doc comment ('If scopes overlap, full discovery owns the reset'): inside the options.preserveRefusals !== true branch also clear pendingRefusalNames and pendingRefusalTransports. This is complementary to wiring clearRefusal into every connect path — it closes the window for a refusal recorded by a scope that has not yet flushed, including one for a server the nested discovery no longer desires at all.

Witness:

Executed at fb125a154b against the real WorkspaceMcpBudget, one script, two arms differing only in the candidate fix. ARM as-is: tryReserve(z) inside the recovery scope -> refused; tryReserve(z) inside the nested discovery scope -> reserved; result {"reserved":["z"],"refused":["z"],"events":["budget_warning:[]","budget_warning:[]","refused_batch:[{\"name\":\"z\",\"transport\":\"stdio\",\"reason\":\"budget_exhausted\"]"]}. ARM fixed (pendingRefusalNames/pendingRefusalTransports cleared at the nested plain beginBulkPass): {"reserved":["z"],"refused":[],"events":["budget_warning:[]","budget_warning:[]"]}.

The fix must not violate this existing fact: The same test file's existing assertion must stay green: after recordRefusal('old') at depth 0, a preserve scope, a nested plain scope, recordRefusal('current'), and two closes, it expects budget.getRefusedServerNames() to equal ['current'] — clearing pending at the nested beginBulkPass must not drop a refusal recorded AFTER that point.

Acceptance criterion: packages/core/src/tools/mcp-workspace-budget.test.ts — extend 'lets full discovery reset refusals when overlapping a partial recovery' to call recordRefusal('old','stdio') INSIDE the preserve scope, before the nested plain beginBulkPass(), and assert getRefusedServerNames() is [] after both endBulkPass() calls. Without the pending clear it returns ['old'] (exactly what the probe printed). Please prove it by mutation — remove the guard, run that test, and confirm it goes red.

中文说明

非 preserve 的 beginBulkPass 只重置已发布的拒绝快照,不会重置外层作用域记录的 pendingRefusal* 集合,因此在最外层 endBulkPass 时,一个在重置之前记录的拒绝会在重置之后被发布出来——而发布的对象正是嵌套的全量发现刚刚连接成功的服务器。实测:嵌套场景下结果为 {reserved:[z], refused:[z]} 并发出指名 z 的 refused_batch 事件;在嵌套的普通 beginBulkPass 中同时清空 pending 集合后变为 {reserved:[z], refused:[]}。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-3: [certifies-falsely] A non-preserve beginBulkPass resets only the published refusal snapshot (lastRefusedServerNames / lastRefusedTransports), never the pendingRefusalNames / pendingRefusalTransports recorded by an enclosing scope, so at the outermost endBulkPass a refusal recorded BEFORE the reset is published AFTER it.

Failure scenario: Recovery (depth 1, preserveRefusals: true) records a budget refusal for srv into the pending sets. A nested full discovery then calls beginBulkPass() with no options (mcp-client-manager.ts:1591), which clears the published snapshot but leaves srv in pendingRefusalNames — and full discovery never calls clearRefusal for its successes (only the recovery path does, at :1887). At the outermost endBulkPass, flushRefusedBatch iterates the stale pending set and republishes srv into lastRefusedTransportslastRefusedServerNames and emits a refused_batch frame naming a server the nested pass actually admitted. The published refusal list reports a server as refused that is connected.

Witness:

[review] direct read at `af2769a2a2`: `mcp-workspace-budget.ts:232-238` — `beginBulkPass` touches only `this.lastRefusedServerNames = []` and `this.lastRefusedTransports.clear()` when `preserveRefusals !== true`; neither pending set is cleared. `:275-300` — `flushRefusedBatch` iterates `this.pendingRefusalNames`, sets `this.lastRefusedTransports.set(name, transport)` for each, then recomputes `lastRefusedServerNames` from those keys and emits `refused_batch`. Call sites: `beginBulkPass()` non-preserve at `mcp-client-manager.ts:1591`, `beginBulkPass({ preserveRefusals: true })` at `:1848`; `clearRefusal` only at `:1887`.

Suggested fix: Decide which scope owns a pending refusal entry instead of clearing only the published snapshot.

The fix must not violate this existing fact: mcp-workspace-budget.ts:229-231 — the class doc states "If scopes overlap, full discovery owns the reset; pending results from either scope share the final flush", so the fix must decide which scope owns a pending entry rather than clearing both sets unconditionally, or a legitimate recovery refusal recorded by the enclosing scope is lost.

Acceptance criterion: A case in packages/core/src/tools/mcp-workspace-budget.test.ts that records a refusal inside an outer preserve pass, opens and closes a nested non-preserve pass which admits the same server, and asserts the outermost endBulkPass neither republishes the name nor emits a refused_batch frame for it. Please prove it by mutation - remove the guard, run that test, confirm it goes red.

中文说明

R6-3 仍然存在。非 preserve 的 beginBulkPass 只重置对外发布的拒绝快照(lastRefusedServerNames / lastRefusedTransports),从不重置外层作用域记录的 pendingRefusalNames / pendingRefusalTransports,因此在最外层 endBulkPass 时,一个在重置之前记录的拒绝会在重置之后被发布出去。

失败场景: 恢复流程(深度 1,preserveRefusals: true)把 srv 的预算拒绝记入 pending 集合。随后嵌套的完整发现调用不带参数的 beginBulkPass()mcp-client-manager.ts:1591),它清空了发布快照却把 srv 留在 pendingRefusalNames 中——而完整发现从不为成功项调用 clearRefusal(只有恢复路径在 :1887 调用)。在最外层 endBulkPassflushRefusedBatch 遍历这份陈旧的 pending 集合,把 srv 重新写入 lastRefusedTransportslastRefusedServerNames 并发出一帧 refused_batch,点名一个嵌套过程其实已经放行的服务器。对外发布的拒绝列表把一个已连接的服务器报告为被拒绝。

建议修复: 类注释(mcp-workspace-budget.ts:229-231)写明“作用域重叠时由完整发现负责重置;两个作用域的 pending 结果共享最终 flush”,因此修复需要决定 pending 条目归属哪个作用域,而不是无条件清空两个集合,否则外层作用域合法的恢复拒绝会丢失。

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


Shared tools do not reconnect or replay a failed invocation themselves, even when annotated read-only or idempotent. A lost response may hide a completed operation: check its outcome before requesting that operation again. Recovery does not execute any tool or reuse a previous permission decision.

Disabled, removed, explicitly disconnected, untrusted, or pending-approval servers are not started by this recovery path. Initial discovery failures and timeouts do not become recovery candidates; correct the configuration and use the existing management controls. Authentication continues through the existing MCP transport and authentication flow.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-4: [certifies-falsely] [new-surface] The recovery section this diff adds states that initial discovery failures and timeouts never become recovery candidates, but the same diff records a discovery-pass failure in failedPooledConnections (mcp-client-manager.ts:1739-1749) and recoverFailedConnections turns every entry of that map into a respawn attempt on the next model send. A server whose handle is already terminal when discovery acquires it therefore IS re-acquired per turn, and an operator reading the added guarantee rules this path out and looks elsewhere — at the health monitor or their own configuration — while the respawn keeps happening. Correct the sentence to the code: only a startup failure that makes acquire itself reject (a spawn throw, a discovery timeout) leaves no candidate; a handle already failed when discovery attaches one does. If the documented guarantee is the intended contract instead, stop recording the post-acquire failed arm in runDiscoverAllMcpToolsViaPool. Two narrative corrections from verification that do not change the verdict: acquire cannot fast-path-attach onto a failed entry (mcp-transport-pool.ts:316-320 requires !existing.isTerminated()), and a server that dies during startup makes spawnEntry throw so acquire rejects and the outer catch records nothing — the 'timeouts' half of the sentence is true; the contradiction is the post-acquire failed arm only.

Witness:

Executed at fb125a154b against a real McpClientManager.discoverAllMcpToolsIncremental (the daemon initial pool-discovery entry point, acpAgent.ts:3990) with a pool whose acquire resolves an already-failed handle: "RA22 after discovery pass: failedPooledConnections = [[\"srv\",{\"transportId\":\"srv::49f20c3551319678\"}]] | pooledConnections.size = 0 | conn.release called = 1" then "RA22 recoverFailedConnections: acquireForRecovery calls = 1 | notices = [] | failedPooledConnections now = []". Code: mcp-client-manager.ts:1739-1749.

The fix must not violate this existing fact: The design record names this arm deliberately — "or when an acquired handle has already failed before the session attaches its listener" — so the user doc, not the code, is what must change unless the maintainer decides otherwise. Two narrative corrections from verification that do not change the verdict: acquire cannot fast-path-attach onto a failed entry (mcp-transport-pool.ts:316-320 requires !existing.isTerminated(), and :523-528 rejects a torn-down entry after the spawn await), and a server that dies during startup makes spawnEntry throw so acquire rejects and the outer catch records nothing — the "timeouts" half of the sentence is true.

Acceptance criterion: A packages/core/src/tools/mcp-client-manager.lifecycle.test.ts case that drives one pooled discovery pass whose acquire resolves an already-failed handle and asserts failedPooledConnections stays empty; it goes red against mcp-client-manager.ts:1746 as written. Please prove it by mutation — remove the guard, run that test, and confirm it goes red.

中文说明

本次新增的恢复章节承诺「初始发现失败与超时不会成为恢复候选」,但同一个改动在池化发现过程中把失败写入 failedPooledConnections,下一次模型发送就会重新拉起它。实测:一次 acquire 返回已失败句柄的发现过程结束后,failedPooledConnections 持有该条目,随后 recoverFailedConnections 调用了一次 acquireForRecovery。运维人员读到这句保证后会排除该路径,转而去查健康监控或自己的配置。应修正文档以匹配代码(或反过来,如果文档才是预期契约)。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-4: [certifies-falsely] [new-surface] The recovery section this diff adds states that initial discovery failures and timeouts never become recovery candidates, but the same diff records a discovery-pass failure in failedPooledConnections and recoverFailedConnections turns every entry of that map into a respawn attempt on the next model send.

Failure scenario: A server whose pool handle is already terminal when discovery attaches it hits mcp-client-manager.ts:1739-1740 (throw new Error("MCP connection closed during discovery")), and the catch at :1745-1748 records failedPooledConnections.set(name, …). That map is exactly the candidate set recoverFailedConnections reads, so the server IS re-acquired on every model send. An operator reading the added guarantee rules this path out and looks elsewhere — at the health monitor or their own configuration — while the per-turn respawn keeps happening.

Witness:

[review] read at `af2769a2a2` — `mcp-client-manager.ts:1739-1752`: `if (conn.state === "closed" || conn.state === "failed") throw …; } catch (error) { … if (conn.state === "failed") { this.failedPooledConnections.set(name, { transportId: conn.transportId }); } conn.release(); throw error; }`. The documented sentence is present verbatim at `docs/users/features/mcp.md:314`. Round 6 measured the same path end-to-end: `failedPooledConnections = [["srv",{"transportId":"srv::49f20c3551319678"}]]` after a discovery pass, then `acquireForRecovery calls = 1`.

Suggested fix: Correct the sentence to the code, or stop recording the post-acquire failed arm in runDiscoverAllMcpToolsViaPool if the documented guarantee is the intended contract.

The fix must not violate this existing fact: Round 6's two verified narrative corrections still hold and bound the reword: acquire cannot fast-path-attach onto a failed entry (mcp-transport-pool.ts:316-320 requires !existing.isTerminated()), and a server that dies during startup makes spawnEntry throw so acquire rejects and the outer catch records nothing — so the "timeouts" half of the sentence is true and only the post-acquire failed arm contradicts it.

中文说明

R6-4 仍然存在。本 diff 新增的恢复章节声称“初始发现失败与超时不会成为恢复候选”,但同一个 diff 会把发现过程中的失败记入 failedPooledConnections,而 recoverFailedConnections 会把该表中的每一项都变成下一次模型发送时的重启尝试。

失败场景: 当发现流程附着到一个已处于终止态的池句柄时,会命中 mcp-client-manager.ts:1739-1740(抛出 MCP connection closed during discovery),其 catch 在 :1745-1748 记录 failedPooledConnections.set(name, …)。该表正是 recoverFailedConnections 读取的候选集合,所以该服务器确实会在每次模型发送时被重新获取。读到这条新增保证的运维者会把这条路径排除掉,转而去查健康监控或自己的配置,而每轮重启仍在继续发生。

建议修复: 请把这句话改成与代码一致:只有使 acquire 本身拒绝的启动失败(spawn 抛错、发现超时)才不留候选;发现时附着到一个已经 failed 的句柄则会留下候选。若这条文档保证才是预期契约,则应改代码:在 runDiscoverAllMcpToolsViaPool 中不再记录 acquire 之后的 failed 分支。

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

@wenshao

wenshao commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

Both this branch and main inserted new Session fields at the same anchor
after `private turn`, so the declaration block conflicted. The two sets
are independent: the recovery notice fields gate per-turn MCP recovery
diagnostics, and main's nested-sequence counter names code-mode sub-calls.
Keep both rather than choosing a side.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

PR #11392 — conflict resolution

Merged origin/main (fdb33117b4); one hunk in packages/cli/src/acp-integration/session/Session.ts.

Root cause

main's #10607 (4ec9f63d0a, CodeModeOnly tool execution) inserted private codeModeNestedSequence = 0; into the Session field block at the anchor this PR's 89886e994c had claimed — right after private turn: number = 0;. Two independent insertions on one anchor line; git cannot order them. Main had moved 49 commits / 844 files, and this was the only conflict.

Textual, not semantic

Adjacent only; neither side reads the other's field. Union:

private turn: number = 0;
private mcpRecoveryNoticeTurn?: string;
private readonly mcpRecoveryNotices = new Map<string, string>();
private codeModeNestedSequence = 0;

Both are live (Session.ts:13896, 8093-8100), so neither was droppable.

Load-bearing

  • All three fields must stay declared. Order is not load-bearing (no interdependencies); do not regroup them assuming position matters.
  • Two orderings git produced silently that a later edit could break: ToolRegistry.stop() clears copiedMcpTools before draining this.inflight; agent-core.ts runs refreshMcpTools and reassigns toolsList before promptId is built, inside the per-round try. prepareTools() is now also main's CodeModeOnly entry point, so the refresh must stay upstream of it or a recovered server never reaches the exec bindings.

Not verified

No build, typecheck, or tests run.

  • CodeModeOnly × pooled recovery is covered by neither side. feat(core): add CodeModeOnly tool execution #10607 makes getFunctionDeclarations* / getDeferredToolSummary return code-mode projections; this PR's refreshMcpTools() mutates this.tools mid-session, and those mutations now feed planCodeModeBindings — so a recovery changes the exec tool's bound schemas.
  • Session.test.ts (248 PR lines) merged with main's edits to the same file (main also made #settleTurnRecording async); the suite never ran.
  • The PR widened Config.getMcpServers(includeExtensionMetadata = true): backward compatible, and main did not touch the context interface — but I did not enumerate its new callers.
中文说明

Session.ts 一处冲突。

根因:main 的 #106074ec9f63d0a,CodeModeOnly)在 Session 字段块紧接 private turn: number = 0; 后插入 codeModeNestedSequence,正是本 PR(89886e994c)插入两个 MCP 恢复字段之处;两次无关插入同锚点,git 无法排序。main 已前进 49 提交、844 文件,此为唯一冲突。

纯文本冲突,非语义冲突:两侧仅相邻,互不读写对方字段,故取并集。两字段均在用(Session.ts:138968093-8100),不可丢弃任一侧。

关键约束:三字段须保留声明;顺序不重要(无相互依赖),勿以为位置有意义而重排。另两处 git 静默产出、易被后续修改破坏的顺序:ToolRegistry.stop() 须在排空 this.inflight 清理 copiedMcpToolsagent-core.ts 须在构造 promptId 、每轮 try 内完成 refreshMcpTools 并重赋 toolsListprepareTools() 现亦是 CodeModeOnly 入口,刷新须在其上游,否则恢复的服务器进不了 exec 绑定。

未能验证:未跑构建、类型检查或测试。CodeModeOnly 与连接池恢复的组合两侧均无覆盖:#10607getFunctionDeclarations*getDeferredToolSummary 返回 code-mode 投影,本 PR 的 refreshMcpTools() 则会在会话中改写 this.tools,该改写现进入 planCodeModeBindings,故一次恢复会改变 exec 绑定的 schema。Session.test.ts(PR 新增 248 行)已与 main 对同文件的修改(含 #settleTurnRecording 转 async)合并,测试未跑。PR 放宽的 Config.getMcpServers(includeExtensionMetadata = true) 向后兼容、main 未改上下文接口,但未枚举其新增调用方。

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

⚠️ Round 7, and the diff has grown 5.0x since this review first measured it (512 → 2551 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R7-11 mcp-tool.ts:582 standalone-path JSON-RPC application error misread as a dead connection — already reported as R6-1 (comment 3989203341 by qwen-code-ci-bot), re-posted under that id this round

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

Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": whether the MCP SDK ever delivers a genuine HTTP 404 dead-session failure as a brand-matched ProtocolError (which would make isMcpDeadSessionHttpError unrea…; chunk 2: running the six new Session.test.ts cases ( npx vitest run src/acp-integration/session/Session.test.ts -t 'MCP demand recovery' ) to confirm they pass as writ…; chunk 2: verifying the doc clause "daemon sessions (including Channel sessions) share eligible MCP connections, including stdio processes" against isPoolable / the def…; chunk 2: verifying "Cancelling a tool call sends a request cancellation" (that the pooled path actually issues a JSON-RPC cancel notification); chunk 2: reading the explicit-disconnect sites that delete failedPooledConnections ( mcp-client-manager.ts:2104 , :2803 , :3579 ) backing "explicitly disconnected … …, and 9 more.

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

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

  • docs/design/2026 — [review] Both design docs attribute the only real-process teardown-la
  • docs/design/2026 — [review] The Chinese design drops the turn scope that the English ver
  • docs/design/2026-09-08-pooled-mcp-recovery.zh-CN.md:18 — [review] The Chinese design omits an English-version decision — docs
  • docs/users/features/mcp.md:310 — [review] Unqualified generalization. Recovery acquires with the *call
  • docs/users/features/mcp.md:310 — [review] The documented negative "There is no background reconnect ti
  • docs/users/features/mcp.md:310 — [review] The added recovery section documents an exactly-once-per-tur
  • packages/cli/src/acp-integration/session/Session.ts:8082 — [review] The manager's recovery notices are *consumed* (its recovery
  • packages/cli/src/acp-integration/session/Session.ts:8085 — [review] With pooling enabled the gate is unconditionally true, so ev
  • packages/core/src/subagents/subagent-manager.ts:1258 — [review] The new prompt/resource isolation is gated on a transport po
  • packages/core/src/tools/mcp — [review] The EXECUTION_TIMEOUT classification this diff re-plumbs and
  • packages/core/src/tools/mcp — [review] The new four-copy test pins only reconnectOnError propagat
  • packages/core/src/tools/mcp — [review] The only test of the new trust/approval gate drives a stdio
  • packages/core/src/tools/mcp — [review] The catch-path !stillWanted() guard added by this diff (di
  • packages/core/src/tools/mcp — [review] recoverFailedConnections returns the whole recoveryNotice
  • packages/core/src/tools/mcp — [review] The new pre-spawn cleanup barrier only sees teardowns publis
  • packages/core/src/tools/mcp — [review] Adding the status branch makes a previously-dead structura
  • packages/core/src/tools/mcp — [review] A recovery pass re-emits mcp_child_refused_batch for a ser
  • packages/core/src/tools/mcp — [review] The refusal-set lifecycle change falsifies five standing sta
  • packages/core/src/tools/mcp — [review] The post-acquisition recheck on the recovery path is exercis
  • packages/core/src/tools/mcp — [review] The 'is restarting' branch of the recovery notice this PR
  • …and 19 more (see the run report)
中文说明

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

⚠️ 第 7 轮,且自本审查首次测量以来 diff 已增长 5.0 倍(源码 diff 行数 512 → 2551)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

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

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)"whether the MCP SDK ever delivers a genuine HTTP 404 dead-session failure as a brand-matched ProtocolError (which would make isMcpDeadSessionHttpError unrea…;chunk 2:running the six new Session.test.ts cases ( npx vitest run src/acp-integration/session/Session.test.ts -t 'MCP demand recovery' ) to confirm they pass as writ…;chunk 2:verifying the doc clause "daemon sessions (including Channel sessions) share eligible MCP connections, including stdio processes" against isPoolable / the def…;chunk 2:verifying "Cancelling a tool call sends a request cancellation" (that the pooled path actually issues a JSON-RPC cancel notification);chunk 2:reading the explicit-disconnect sites that delete failedPooledConnections ( mcp-client-manager.ts:2104 , :2803 , :3579 ) backing "explicitly disconnected … …,另有 9 条。

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

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

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

@destire-mio

Copy link
Copy Markdown
Contributor Author

Splitting this per the review guidance (thank you @doudouOUC for the detailed scope analysis).

Closing the combined PR so each piece can be reviewed on its own. The reproduction/design notes remain in the thread and in the new bilingual design note under docs/design/.

destire-mio added a commit to destire-mio/qwen-code that referenced this pull request Sep 12, 2026
Infrastructure half of QwenLM#11392, split out per review guidance. Covers the
pool ownership and teardown paths that demand recovery will build on.

- PoolEntry: publish a cleanup barrier, expose waitForCleanup(), and scope
  detach/release to a handle identity so a superseded handle cannot detach
  its replacement.
- McpTransportPool: index attachments by (logical session, registry seat)
  so one session's registries release independently; track retiring entries
  and wait, under a deadline covering the teardown budget, for in-flight
  cleanup before a new process is spawned for the same fingerprint.
- McpClient: reflect an unexpected SDK close in a pool-managed client's
  status, retire the previous transport on disconnect, and bound
  transport.close()/client.close() so the cleanup barrier always settles.

Behaviour change: a pool-managed client now reports DISCONNECTED on an
unexpected transport close, which evicts the entry and removes the
session's MCP tools. Before, an EOF-killed server was invisible. This is
the detection half of QwenLM#11272; re-acquisition is a follow-up.

No recovery trigger, retry cooldown, budget refusal accounting, or
raw-recipe access is included; those land with the follow-up that calls
them.
destire-mio added a commit to destire-mio/qwen-code that referenced this pull request Sep 12, 2026
Bilingual design note for the infrastructure half of QwenLM#11392: the pool
ownership and teardown invariants, the unexpected-close behaviour change,
risks, and validation plan. English and Simplified Chinese are aligned.
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.

6 participants