From 78e285f06c4c17bcbdef27709731b2c8bdd27d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Mon, 31 Aug 2026 10:35:36 +0800 Subject: [PATCH 1/5] feat(serve): establish workspace runtime ownership --- .../daemon-multi-workspace-hardening.md | 26 +- docs/design/session-idle-reaper/README.md | 34 +- docs/design/workspace-runtime-architecture.md | 1062 +++++++++++++ docs/developers/daemon/00-index.md | 2 +- docs/developers/daemon/03-acp-bridge.md | 8 +- .../daemon/05-mcp-transport-pool.md | 2 +- .../developers/daemon/08-session-lifecycle.md | 21 +- docs/developers/daemon/17-configuration.md | 8 +- .../daemon/20-quickstart-operations.md | 6 +- docs/developers/qwen-serve-protocol.md | 5 +- docs/users/qwen-serve-deploy-local.md | 6 +- .../cli/qwen-serve-routes.test.ts | 1 + packages/acp-bridge/src/bridge.test.ts | 1185 +++++++++++++- packages/acp-bridge/src/bridge.ts | 1357 ++++++++++------- packages/acp-bridge/src/bridgeOptions.ts | 20 +- packages/acp-bridge/src/bridgeTypes.ts | 16 +- packages/acp-bridge/src/channel.ts | 1 + packages/acp-bridge/src/spawnChannel.test.ts | 34 +- packages/acp-bridge/src/spawnChannel.ts | 25 +- packages/acp-bridge/src/status.ts | 8 + packages/cli/src/commands/serve.ts | 6 +- packages/cli/src/serve/acp-session-bridge.ts | 2 + packages/cli/src/serve/capabilities.ts | 7 + .../serve/routes/workspace-management.test.ts | 60 + .../src/serve/routes/workspace-management.ts | 21 + .../serve/routes/workspace-runtime.test.ts | 238 +++ .../cli/src/serve/routes/workspace-runtime.ts | 121 ++ packages/cli/src/serve/run-qwen-serve.test.ts | 228 ++- packages/cli/src/serve/run-qwen-serve.ts | 66 +- packages/cli/src/serve/server.test.ts | 16 + packages/cli/src/serve/server.ts | 26 + .../src/serve/server/error-response.test.ts | 69 +- .../cli/src/serve/server/error-response.ts | 104 +- .../cli/src/serve/server/serve-features.ts | 3 + packages/cli/src/serve/types.ts | 2 +- packages/cli/src/serve/workspace-registry.ts | 2 + .../workspace-runtime-coordinator.test.ts | 207 +++ .../serve/workspace-runtime-coordinator.ts | 150 ++ .../__tests__/facade.test.ts | 22 +- .../cli/src/serve/workspace-service/index.ts | 48 +- .../sdk-typescript/src/daemon/DaemonClient.ts | 50 + packages/sdk-typescript/src/daemon/index.ts | 1 + packages/sdk-typescript/src/daemon/types.ts | 9 + packages/sdk-typescript/src/index.ts | 1 + .../test/unit/DaemonClient.test.ts | 126 ++ 45 files changed, 4672 insertions(+), 740 deletions(-) create mode 100644 docs/design/workspace-runtime-architecture.md create mode 100644 packages/cli/src/serve/routes/workspace-runtime.test.ts create mode 100644 packages/cli/src/serve/routes/workspace-runtime.ts create mode 100644 packages/cli/src/serve/workspace-runtime-coordinator.test.ts create mode 100644 packages/cli/src/serve/workspace-runtime-coordinator.ts diff --git a/docs/design/daemon-multi-workspace-hardening.md b/docs/design/daemon-multi-workspace-hardening.md index 1eecb434b32..9c4eae23fef 100644 --- a/docs/design/daemon-multi-workspace-hardening.md +++ b/docs/design/daemon-multi-workspace-hardening.md @@ -23,14 +23,14 @@ fallback when resolution fails. ## Failure semantics -| State | Required behavior | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Unknown workspace or session | Fail closed with the route's stable mismatch/not-found response. Do not probe or execute against primary. | -| Untrusted workspace | Reject runtime-backed execution and mutation. An untrusted secondary may use only explicitly documented read-only surfaces, including bounded filesystem and persisted catalog/transcript reads, without starting ACP or writing repair state. Legacy primary preheat does not authorize requests. | -| Ambiguous live-session owner | Return a server error because dispatch cannot be made safely. Execute on no bridge. | -| Bootstrapping runtime | Keep process-global liveness responsive; runtime-backed work waits for or reports the declared startup failure. Deep health returns `503` with a reason while aggregation is unavailable. | -| Draining runtime | Refuse new work with the stable draining response. A non-forced removal rolls back with `workspace_busy` if activity exists; a forced removal requests termination and bounded cleanup of active resources. The runtime remains in daemon-global accounting until removal completes. | -| Removed runtime | Treat it as unknown. It must disappear from capabilities, routing, and health aggregation before the same workspace can be re-added. Cleanup after the persistence commit point is best-effort; failures are logged and do not restore routing. | +| State | Required behavior | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unknown workspace or session | Fail closed with the route's stable mismatch/not-found response. Do not probe or execute against primary. | +| Untrusted workspace | Reject runtime-backed execution and mutation. An untrusted secondary may use only explicitly documented read-only surfaces, including bounded filesystem and persisted catalog/transcript reads, without starting ACP or writing repair state. Primary compatibility routing does not authorize requests. | +| Ambiguous live-session owner | Return a server error because dispatch cannot be made safely. Execute on no bridge. | +| Bootstrapping runtime | Keep process-global liveness responsive; runtime-backed work waits for or reports the declared startup failure. Deep health returns `503` with a reason while aggregation is unavailable. | +| Draining runtime | Refuse new work with the stable draining response. A non-forced removal rolls back with `workspace_busy` if activity exists; a forced removal requests termination and bounded cleanup of active resources. The runtime remains in daemon-global accounting until removal completes. | +| Removed runtime | Treat it as unknown. It must disappear from capabilities, routing, and health aggregation before the same workspace can be re-added. Cleanup after the persistence commit point is best-effort; failures are logged and do not restore routing. | ## Invariants @@ -40,11 +40,11 @@ fallback when resolution fails. be absolute and canonicalize to a registered runtime. - Each active workspace runtime owns its environment snapshot, bridge, workspace services, filesystem/trust boundary, Voice state, and ACP/MCP resource - boundary. Production attempts to preheat the primary bridge for compatibility - and retries on first use after a preheat failure. A trusted secondary starts - its ACP child on demand and, when `mcp_workspace_pool` is enabled, owns the - pool inside that child; an untrusted secondary must not start either. Primary - preheat does not bypass route trust gates. A + boundary. Production may preheat the trusted primary child for compatibility; + trusted secondaries start on first runtime-backed use. When + `mcp_workspace_pool` is enabled, each started child owns its pool; an untrusted + workspace must not start either. Primary compatibility routing does not bypass + route trust gates. A process-global Voice coordinator enforces the shared admission cap while tracking leases by owning runtime. Same-named environment keys must not cross runtimes, and a workspace overlay must not mutate the parent process diff --git a/docs/design/session-idle-reaper/README.md b/docs/design/session-idle-reaper/README.md index 112bc071cf4..e3d7d06b72d 100644 --- a/docs/design/session-idle-reaper/README.md +++ b/docs/design/session-idle-reaper/README.md @@ -92,7 +92,7 @@ Bridge closure (createHttpAcpBridge) | Mechanism | Scope | What it manages | | ----------------------------------------- | ------------------------- | -------------------------------------------------------------------------------- | -| `channelIdleTimeoutMs` + `startIdleTimer` | Channel (child process) | Kills the `qwen --acp` child when ALL sessions are gone | +| `channelIdleTimeoutMs` + `startIdleTimer` | Channel (child process) | Unset or `0` reaps immediately; a positive value delays reap | | **Session reaper** (this design) | Session (in-memory entry) | Closes individual sessions when idle | | `ConnectionRegistry` sweep | ACP-over-HTTP connection | Reaps `/acp` transport-layer connections (different layer) | | `writerIdleTimeoutMs` | SSE subscriber | Evicts a single stuck SSE subscriber | @@ -309,6 +309,8 @@ function. - `startSessionReaper()` is called at bridge construction time (after option validation, alongside the existing `channelIdleTimeoutMs` setup). + Omitting that channel option or setting it to `0` reaps an idle Workspace + Runtime immediately; configured values must be non-negative. - `stopSessionReaper()` is called in both `shutdown()` and `killAllSync()`. ### 4.6 Interaction with existing `closeSession` callers @@ -367,20 +369,20 @@ generic terminal-frame handler (`isTerminalLifecycleEvent`) already handles ### 5.1 Unit tests (`bridge.test.ts`) -| # | Test | Description | -| --- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Idle session is reaped after timeout | Create a session, advance time past `sessionIdleTimeoutMs`, trigger reaper tick, verify session removed from `byId` and `session_closed` event published with `reason: 'idle_timeout'` | -| 2 | Session with active prompt is NOT reaped | Create a session, start a prompt, advance time, verify session survives reaper tick | -| 3 | Session with live SSE subscriber is NOT reaped | Create a session, subscribe to its EventBus, advance time, verify session survives | -| 4 | Session with registered client is NOT reaped | Create a session, register a clientId, advance time, verify session survives | -| 5 | Reaper disabled when interval = 0 | Pass `sessionReapIntervalMs: 0`, verify no `setInterval` is armed | -| 6 | Reaper disabled when timeout = 0 | Pass `sessionIdleTimeoutMs: 0`, verify no `setInterval` is armed | -| 7 | Reaper stopped on shutdown | Call `shutdown()`, verify `clearInterval` was called | -| 8 | closeSession reason defaults to 'client_close' | Call `closeSession` without explicit reason, verify published event has `reason: 'client_close'` | -| 9 | closeSession with explicit reason | Call `closeSession` with `reason: 'idle_timeout'`, verify published event | -| 10 | Multiple idle sessions reaped in one tick | Create 3 idle sessions, advance time, trigger tick, verify all 3 reaped | -| 11 | Session with heartbeat within TTL survives | Create a session, record heartbeat, advance time to just under TTL, verify session survives | -| 12 | Channel idle timer triggered after last session reaped | Create 1 session (last on channel), reap it, verify `startIdleTimer` is called on the channel | +| # | Test | Description | +| --- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Idle session is reaped after timeout | Create a session, advance time past `sessionIdleTimeoutMs`, trigger reaper tick, verify session removed from `byId` and `session_closed` event published with `reason: 'idle_timeout'` | +| 2 | Session with active prompt is NOT reaped | Create a session, start a prompt, advance time, verify session survives reaper tick | +| 3 | Session with live SSE subscriber is NOT reaped | Create a session, subscribe to its EventBus, advance time, verify session survives | +| 4 | Session with registered client is NOT reaped | Create a session, register a clientId, advance time, verify session survives | +| 5 | Reaper disabled when interval = 0 | Pass `sessionReapIntervalMs: 0`, verify no `setInterval` is armed | +| 6 | Reaper disabled when timeout = 0 | Pass `sessionIdleTimeoutMs: 0`, verify no `setInterval` is armed | +| 7 | Reaper stopped on shutdown | Call `shutdown()`, verify `clearInterval` was called | +| 8 | closeSession reason defaults to 'client_close' | Call `closeSession` without explicit reason, verify published event has `reason: 'client_close'` | +| 9 | closeSession with explicit reason | Call `closeSession` with `reason: 'idle_timeout'`, verify published event | +| 10 | Multiple idle sessions reaped in one tick | Create 3 idle sessions, advance time, trigger tick, verify all 3 reaped | +| 11 | Session with heartbeat within TTL survives | Create a session, record heartbeat, advance time to just under TTL, verify session survives | +| 12 | Channel idle policy evaluated after last session reaped | Create 1 session (last on channel), reap it, verify an unset timeout reaps the channel and an explicit positive timeout arms the compatibility timer | ### 5.2 Integration tests (`server.test.ts`) @@ -431,4 +433,4 @@ generic terminal-frame handler (`isTerminalLifecycleEvent`) already handles | `closeSession` inside reaper throws, poisoning the scan loop | Each close is in its own `.catch()` — one failure doesn't block others | | Reaper iteration over `byId` during concurrent `closeSession` from another path | ES2015 Map iteration tolerates deletion of current/previous keys. Double-close is idempotent (`byId.get` returns undefined → `SessionNotFoundError` caught by reaper's `.catch`). | | Performance of scanning 20 sessions every 60s | Trivial — 20 Map reads + 4 field checks each. No I/O. | -| Channel idle timer interaction | When the last session is reaped, `closeSession` already calls `startIdleTimer` on the channel. No additional logic needed. | +| Channel idle timer interaction | When the last session is reaped, `closeSession` calls `startIdleTimer`; an unset or zero timeout reaps immediately, while an explicit positive timeout arms the timer. | diff --git a/docs/design/workspace-runtime-architecture.md b/docs/design/workspace-runtime-architecture.md new file mode 100644 index 00000000000..0adbf8b6411 --- /dev/null +++ b/docs/design/workspace-runtime-architecture.md @@ -0,0 +1,1062 @@ +# Qwen Serve 工作区运行时中心架构 + +## 1. 文档定位 + +本文是 `qwen serve` 从 Session-centric 迁移到 +Workspace-runtime-centric 的目标设计与堆叠交付契约。该设计由四个可独立合并的 +PR 渐进落地;本文描述最终形态,不表示 foundation PR 已实现所有 capability。 + +> **当前落地进度(foundation)**:已实现 Bridge 权威的五态 lifecycle snapshot、 +> workspace 级单调 epoch、完整物理 work lease、绝对启动 deadline、无参数 +> `ensure/status`、10 分钟可续期保活、drain/removal/shutdown admission,以及 +> SDK 的 primary/qualified runtime 方法。第 10~13 节描述的 capability +> generation/revision、Catalog 投影和 operation 状态机仍属于后续阶段;当前 +> `ensure` 只保证 ACP runtime 已初始化并可复用,不宣称各领域 Catalog 已 ready。 + +### 1.1 当前实现与目标设计 + +为避免把后续阶段的契约误读为 foundation 已有行为,本文使用以下标记: + +- **Foundation(已实现)**:当前代码和 API 可以依赖的行为; +- **Target(未实现)**:后续 PR 的目标契约,当前调用方不得依赖; +- **Legacy(兼容)**:迁移期间保留的旧入口,新调用方不应采用。 + +| 领域 | Foundation(已实现) | Target(后续阶段) | +| ----------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Runtime lifecycle | Bridge 权威五态、workspace 单调 epoch、物理 work lease、启动 deadline、drain/removal admission | capability 健康状态参与统一对外投影 | +| `ensure` | 无参数;仅确保 ACP Channel 完成 initialize;成功后续期 10 分钟 | 准备 Extensions、MCP、Skills、Tools,并通过 capability status 表达收敛 | +| `status` | lifecycle、`runtimeLive`、`runtimeEpoch` 快照 | capability、generation、revision、error 和 operation 投影 | +| SDK | primary/qualified `ensure` 与 `status` 的 REST 方法 | config/runtime Catalog、operation 和统一 deadline 的完整 owner-aware API | + +除明确标为 Foundation 的段落外,第 9~14 节中 capability、Catalog、generation、 +revision 和 operation 的详细状态机均是 Target 契约。 + +核心目标只有一个: + +> Workspace 是运行时、隔离和管理边界;Session 只是 Workspace Runtime +> 中用于对话与执行的消费者。 + +仍保留的旧入口会明确标为兼容 adapter,而不是另一套架构。整个 stack 只有通过 +第 14 节的验收条件后,才算完成迁移。 + +## 2. 背景 + +早期 daemon 以 Session 为入口。创建或加载 Session 后才启动 ACP 子进程, +随后初始化 Config、Skills、Tools、MCP 和 Extensions。管理页面因此逐渐出现了 +多套兜底流程: + +- 为读取运行时状态先预热 ACP; +- 选择一个已有 Session 取得 Config; +- 在前端串联 MCP initialize、reload 和轮询; +- 在最后一个 Session 关闭时顺带回收 ACP; +- ACP 不可用时从 daemon 本地扫描或使用未标明来源的缓存。 + +这些流程把“管理工作区”和“运行一次对话”绑定在一起。它们也让 daemon、Bridge、 +ACP 和前端同时拥有一部分生命周期或状态判断,难以回答以下问题: + +- 没有 Session 时,工作区实际可以使用哪些能力? +- 配置已经保存,是否代表当前运行时已经应用? +- ACP 重启后,缓存是否仍属于当前运行时? +- 最后一个 Session 关闭后,仍在执行的认证或刷新由谁保证完成? + +Workspace-runtime-centric 架构通过一个工作区级运行时解决这些问题,而不是创建 +隐藏 Session 或额外的“管理 Session”。 + +## 3. 目标与非目标 + +### 3.1 目标 + +1. 无 ACP、无 Session 时,仍可完成所有持久化配置和安装操作。 +2. 无 Session 时,可按需启动 Workspace ACP Runtime,取得真实的 Extensions、 + MCP、Skills、Tools 等运行时结果。 +3. 一个工作区在任意时刻最多只有一个当前 Workspace ACP Runtime;多个 Session + 和管理操作复用它。 +4. `WorkspaceRuntime` 聚合是唯一 runtime ownership 边界;Bridge 驱动物理 Channel、 + epoch 和 lease,Coordinator 管理 capability 收敛、operation 和对外投影。二者都是 + 同一个 WorkspaceRuntime 的内部组件,不是并列 runtime owner。 +5. Session 创建和关闭只获取、释放 session lease,不控制 ACP 进程生命周期。 +6. daemon 持久化控制面与工作区实时运行时分离,mutation 结果明确区分 durable + result 和 runtime activation。 +7. 前端只通过无 capability 参数的 `ensureRuntime()` 请求完整 Workspace Runtime, + 再轮询权威 operation/status 和读取 Catalog;前端不编排内部初始化步骤。 +8. 工作区路由严格隔离;未知、未信任、移除中或启动失败的工作区绝不回退到 + primary runtime。 +9. 保留兼容接口并渐进迁移,不重复实现完整 Config,不同时维护管理 ACP 与 + Session ACP 两套进程。 + +### 3.2 非目标 + +- 不让 daemon 重写 ACP 中的完整 Config 初始化。 +- 不把每个 WorkspaceRuntime 拆成独立 daemon 进程。 +- 不保证 ACP 子进程永久驻留。 +- 不为管理和 Session 分别启动两个包含完整 Config 的 ACP 子进程。 +- 不在本次迁移中重写 MCP transport pool 或 Session multiplex 协议。 +- 不为了命名统一而重写稳定的模块实现。 +- 不立即删除旧的 preheat、MCP initialize/reload 等兼容接口。 + +## 4. 必须保持的架构不变量 + +以下条件是实现选择的边界,不是建议: + +1. Workspace 管理接口不接收 `sessionId`,内部不通过 `sessionOrThrow()`、 + `requestSessionStatus()` 或任意 Session 查找 Config。 +2. 管理操作不得创建、恢复、选择或保留隐藏 Session。 +3. Workspace ACP Runtime 属于 `WorkspaceRuntime`,不属于第一个 Session, + 也不由最后一个 Session 决定何时退出。 +4. 每个已解析工作区只访问自己的 environment、Bridge、service、filesystem、 + Config 和缓存。 +5. Qualified runtime command 和敏感 Workspace scope mutation 在目标未知、未信任、 + bootstrapping、draining、removed 或 failed 时明确失败;daemon-local qualified + config GET 只要求 exact resolve,可读取未信任工作区。global config owner 不以 + primary runtime 的 trust 或 lifecycle 为前提,所有路径都不得回退到其他工作区。 +6. 持久化成功与运行时应用成功是两个独立事实;后者失败不能把前者报告成失败。 +7. `ready` 只属于当前 runtime epoch;旧 epoch 的数据最多是 `stale`。 +8. Runtime 路由不持久化配置;配置启用、禁用、安装和删除只由 config/control + 路由执行。 +9. GET 状态和 Catalog 请求不隐式启动 ACP。新调用路径只能通过显式 `ensure`、其他 + runtime command 或 Session 创建来启动;Foundation 暂时保留 production startup + preheat 兼容策略。对外不提供按 capability 选择的启动接口。 +10. 有副作用、需要交互或不能安全重试的长任务通过 operation 暴露终态;幂等的 + capability prepare/reconcile 通过 `/runtime/status` 暴露收敛状态。 +11. 全局配置 owner 与 primary WorkspaceRuntime 是两个概念。全局 owner 不得借 + primary runtime 保存运行时状态,qualified config owner 也不得读取或接管全局 + operation。 + +## 5. 进程与对象模型 + +```text +qwen serve daemon +├── 持久化控制面 +│ ├── Global config owner +│ │ ├── User scope 配置与 Secret +│ │ └── Extension 安装存储与全局 operation +│ ├── Workspace scope 配置 +│ └── Skill 安装存储 +└── WorkspaceRegistry + ├── WorkspaceRuntime(A) + │ ├── WorkspaceRuntimeCoordinator capability/operation 协调者 + │ ├── Workspace config controller 工作区覆盖与其 operation + │ ├── WorkspaceService 本地文件与配置边界 + │ └── Bridge ACP 通信驱动 + │ └── Workspace ACP Runtime 0..1 个子进程 + │ └── Session 0..N 个逻辑 Session + └── WorkspaceRuntime(B) + └── ... 与 A 完全隔离 +``` + +物理上仍复用现有 `qwen --acp` 子进程和 ACP Channel。迁移改变的是所有权: +它们是 Workspace ACP Runtime 的实现细节,不是 Session 级进程。 + +## 6. 唯一所有权 + +### 6.1 持久化控制面与 config owner + +daemon 持久化控制面回答“用户配置了什么”,并且不依赖 ACP 或 Session: + +- User/Workspace Settings; +- MCP 配置、启用状态和 Secret 引用; +- Extension 安装、更新、卸载、全局默认激活和工作区覆盖; +- Skill 安装、卸载和启用状态; +- Tool 启用状态; +- Agent CRUD; +- 工作区注册与信任信息。 + +配置提交生成 desired state。它不直接宣称某个 Workspace Runtime 已应用该状态。 + +全局和工作区 config owner 必须分开: + +- 全局 owner 唯一拥有 Extension 安装、更新、卸载、User scope 激活策略,以及这些 + mutation 的 operation/interaction; +- 每个 qualified workspace config controller 只拥有该工作区的覆盖配置,以及由该 + 路由创建的 operation/interaction; +- `/workspace/config/extensions` 虽保留了 singular/primary 风格的路径名,逻辑上仍 + 指向全局 config owner,不代表 Extension Store 或 operation 属于 primary runtime; +- `/workspaces/:workspace/config/extensions` 必须拒绝安装、更新、卸载和 User scope + enable/disable,也不能查询或响应其他 controller 的 operation/interaction。 + +### 6.2 WorkspaceRuntimeCoordinator + +每个 `WorkspaceRuntime` 持有一个 Coordinator。Foundation Coordinator 只负责 +lifecycle snapshot 投影、ensure/status admission 和 drain/dispose;物理事实仍全部 +来自 Bridge。 + +Target Coordinator 将扩展为以下状态的唯一可写所有者: + +- capability prepare 的合并与执行; +- 当前 epoch 的 capability status; +- Extension desired/applied generation 的工作区投影; +- MCP/Skills capability revision 和“最新尝试获胜”的收敛顺序; +- workspace runtime operation 状态(目标初期为 MCP);Extension config operation + 仍由创建它的全局或 qualified controller 独占; +- 配置变更后的 capability 失效和收敛。 + +调用方不得直接根据 Session 数量或某个模块缓存推断 capability 状态。 + +### 6.3 Bridge + +Bridge 是 WorkspaceRuntime 内由 Coordinator 和兼容 adapter 调用的通信驱动,负责: + +- 启动和停止 ACP 子进程; +- 建立、复用和关闭 Channel; +- 分配单调递增的 runtime epoch; +- 记录 session、handshake、workspace control、discovery 和 auth 的物理 lease; +- 在物理 lease 全部释放后按 idle 策略回收:未配置或 `0` 立即回收,正值延迟回收; +- ACP 请求/响应关联,以及协议支持时的取消; +- 将当前 epoch 的事件和原始 Catalog 快照提供给 Coordinator; +- Session multiplex 的协议适配。 + +Bridge 不负责: + +- 保存跨 epoch 的权威 capability 状态; +- 持久化配置; +- 将旧 runtime 的完成状态合并到新 runtime; +- 把管理请求转发给任意 Session; +- 独立维护另一套 workspace operation 状态机。 + +`AcpSessionBridge` 的 Channel、epoch、物理 lease 和 idle timer 是同一个 +WorkspaceRuntime 的底层生命周期事实,不是第二个 Session runtime。Foundation +Coordinator 只读取 lifecycle snapshot;Target Coordinator 再从这些事实构造 +capability 投影。两者都不复制一套相互竞争的 Channel 状态机。Bridge 只能在所有物理 +lease 均为空时回收,不能仅依据 Session 数量结束 Channel。 + +### 6.4 Workspace ACP Runtime + +ACP Runtime 回答“这个工作区在当前 epoch 实际可以使用什么”,包括: + +- 实际加载的 Extensions 及派生能力; +- 实际加载的 Skills、Commands、Agents、Hooks 和 Context Files; +- 实际注册的 Tools; +- MCP discovery、连接、认证状态、Tools 和 Resources; +- Providers 和依赖完整 Config 的状态; +- 工作区级生成能力。 + +daemon 不复制这些运行时逻辑,只负责协调和观察。 + +### 6.5 Session + +Session 只拥有对话和执行状态:历史、上下文、Turn、模型、Mode、审批和会话临时 +状态。Session 是 Workspace ACP Runtime 的逻辑子对象,不是 Extensions、MCP、 +Skills 或 Tools 管理能力的初始化入口。 + +## 7. User scope 与 Workspace scope + +User scope 是 daemon 进程级的持久化 desired state,不属于 primary runtime。 +primary workspace 只是 singular `/workspace/runtime/...` 兼容路由所选中的普通 +WorkspaceRuntime;它不能因此成为全局配置或全局 operation 的 owner。 + +规则如下: + +1. User scope 变更只通过全局 config owner 提交一次。路径可能保留 + `/workspace/config/...` 这一兼容命名,但其 owner 不能与 primary runtime 的 + Coordinator/controller 合并。 +2. `/workspaces/:workspace/config/...` 只允许 Workspace scope;不得借该路由修改 + User scope。 +3. Extension Store 的 durable mutation 原子推进全局 Extension generation; + MCP/Skills 配置不伪造 store generation,而由每个受影响 Coordinator 推进本地 + capability revision。 +4. 已运行且受信任的工作区异步 reconcile;cold 工作区返回 `deferred`,在下次 + ensure 或创建 Session 时应用。 +5. 对外可观察状态必须在所有受影响的 WorkspaceRuntime 中失效,不能只更新 + primary Bridge。若发布事件,也必须 fan out 到所有受影响客户端。 +6. enable/disable 默认属于 config 路由。旧 `/workspace(s)/.../mcp` 控制接口仅为 + 兼容保留原有持久化行为;新 `/runtime` 路由不提供 enable/disable。 + +一个工作区的 effective desired state 由 User scope、Workspace scope 和已启用 +Extension 的贡献合并而成;合并规则属于 Config/ACP,不在 Coordinator 中复制。 + +operation 的查询与 interaction 回复遵循创建者所有权:全局操作只从全局 controller +查询,工作区操作只从相应 qualified controller 查询。相同 `operationId` 即使出现在 +另一路由的请求里也必须返回 not found,不能通过 daemon 级 pending map 绕过 owner。 + +## 8. 生命周期、lease 与回收 + +### 8.1 生命周期状态机 + +```text +cold -> starting -> active -> idle + | ^ | + | └--------┘ 新 lease + └-> cold + lastError 启动失败 + +active/idle -> stopping -> cold workspace removal / daemon shutdown / explicit restart +stopping -> starting/active 新 epoch(admission 开放时,旧 Channel 退出前) +idle -> stopping -> cold immediate or configured idle timeout +active/idle -> cold child crash +``` + +- `cold`:没有 ACP Channel,也没有正在进行的启动; +- `starting`:Channel 正在创建或 handshake 尚未完成。它是物理 runtime 生命周期 + 状态,不等于某个 capability 的 `starting`; +- `active`:Channel 已 live,且至少有一个 session、workspace-control、discovery、 + auth、spawn/restore 或其他物理 work lease; +- `idle`:Channel 已 live,且没有任何物理 work lease;runtime 继续保留进程和已加载 + 资源,后续工作复用同一 epoch; +- `stopping`:没有可复用的 live Channel,但旧 Channel 仍在异步退出。除 draining、 + removal 或 daemon shutdown 已关闭 admission 外,并发新工作可以在旧 Channel + 完全退出前启动新 epoch;`aliveChannels` 同时跟踪两者以保证最终清理。 + +注册 WorkspaceRuntime 本身不启动 ACP child。Foundation 暂时保留 production +startup preheat,因此受信任的 primary 可在 daemon listen 后被兼容策略启动;不受 +信任的 primary 和所有 secondary 不会被该策略启动。除此之外,Primary 与 secondary +都只由显式 runtime command(包括 `ensure`)或 Session create/load/resume 从 +`cold` 启动。后续移除 startup preheat 后,两者完全一致。 + +若 Channel 已 live,Coordinator 中存在 capability reconcile 并不能单独把顶层状态 +标为 `starting`;实际 RPC 持有 workspace-control/discovery/auth lease 时顶层为 +`active`,lease 释放后为 `idle`。Capability 自己仍可保持 `starting` 或 `error`, +但不会改变顶层五态。只要存在新的可复用 live Channel,顶层就按该 Channel 的 +`active/idle` 投影;旧 epoch 的 Channel 可同时处于退出过程。 + +### 8.2 Lease 模型 + +Foundation Bridge 用 session 集合、spawn/restore 计数、workspace-control 计数、 +MCP discovery 标记和 server-name 级 MCP auth 集合表示物理 work。已接入的 +status、Catalog、Extension refresh、Skills refresh、MCP discovery/auth、普通 runtime +mutation 以及 Session create/load/resume/close 都在对应物理工作期间持 lease。 +物理 startup 本身也受启动 lease 和 deadline 保护。当前 `ensure` 只覆盖 +preheat/initialize,并在成功后登记 keepalive;它尚不串联 capability 阶段。 + +Foundation 在 OAuth 返回 pending 后保留 owning Channel 的 auth lease。明确观察到 +同一 Channel 上的 server 已变为 non-pending 时释放;Catalog 中缺少 server 不是完成 +证据。pending 状态的固定安全期限到达时,Bridge 先对 owning Channel 做最后一次状态确认; +仍无法证明完成时终止该 Channel,以进程退出完成 safe drain。该策略可能结束该 +Channel 上的 Session,因此 deadline 是故障恢复上限,不是普通 idle 回收。 + +Target Coordinator 将通过 Bridge 的外层 runtime-control lease 包住一次完整 +capability runtime command。其中的 Catalog、Extension refresh、Skills refresh 和 +普通 runtime mutation 仍可嵌套使用更具体的计数,但不能在阶段之间释放最后一个物理 +lease。Coordinator 不建立第二套用于物理生命周期的可写“逻辑 lease”。这些计数和 +Map 不对调用方开放。 + +Coordinator 另有仅用于 workspace removal admission 的 daemon-local management +operation 计数。它覆盖尚未进入 Bridge 的配置持久化和后台提交,但不参与顶层 +`active/idle` 投影或 ACP idle 回收判断。 + +约束: + +- Session create/load 获取 session lease,close 只释放自己的 lease; +- Target capability ensure 和需要启动 runtime 的 mutation 在 Channel + 创建/handshake 之前取得外层 runtime-control lease,并连续持有到所有 capability + 阶段和最终状态投影完成;不能在 preheat、Catalog、refresh、discovery 之间留下 + idle 回收窗口; +- 单独的 Catalog RPC、MCP discovery/auth、Extension reconciliation 和 runtime + mutation 在进入物理工作前取得对应的 workspace-control/discovery/auth lease; +- handshake、callback、等待用户输入和清理阶段仍属于操作,lease 不得提前释放; +- 所有请求结束、成功取消或完成 safe drain 后都必须释放自己的 lease;没有取消 + 契约的失败/超时不能仅因观察者停止等待就释放 auth lease; +- 最后一个 Session 关闭不能越过其他 lease 结束 runtime; +- Bridge 的 status/Catalog 请求必须在整个 RPC 期间持 workspace-control lease, + 避免被 idle 回收中断。 + +### 8.3 可配置的 idle 生命周期 + +最后一个物理 work lease 释放后,Bridge 按 `channelIdleTimeoutMs` 立即或延迟回收 ACP +child。进程所有权仍属于 WorkspaceRuntime,回收条件由整个 workspace 的物理 work +lease 决定,而不是只看 Session 数量: + +1. 新 lease 直接复用当前 runtime 和 epoch,并取消已登记的 idle timer; +2. `lastActivityAt` 保持既有 Session 观测语义,只由 Session spawn/restore 和 + prompt 活动更新;workspace runtime 请求通过 lease/keepalive 控制回收,不伪装成 + Session activity; +3. 未配置或显式设为 `0` 时保持既有默认行为:所有物理 work lease 排空后立即回收; +4. 显式正值启用 idle timer;到期时 Bridge 再次确认 session、spawn/restore、 + workspace-control、MCP discovery 和 auth work 均为空后停止 runtime; +5. daemon shutdown 和 workspace removal 可以统一结束对应子进程。 + +成功的显式 `ensure` 会把 workspace 级保活窗口从本次成功时刻续期至少 10 分钟; +并发调用取最长窗口,窗口内再次调用会再次续期。通用部署仍可通过显式正数 +`channelIdleTimeoutMs` 配置更长的 idle 窗口。两者都不计为 active work,workspace +removal 和 daemon shutdown 可以提前结束 runtime。 + +Session 数量不是回收条件,只是 lease 集合的一部分。 + +#### Startup preheat 兼容边界 + +Foundation 保留 production 默认预热受信任 primary 的既有策略,避免尚未迁移到 +`ensure` 的 SDK/API 调用方承担额外首次冷启动延迟。该预热仍通过 primary +WorkspaceRuntime/Bridge 执行,不改变 runtime ownership;不受信任的 primary 不会 +启动 ACP。测试或嵌入方可以通过 `preheatBridge: false` 显式关闭。 + +startup preheat 是迁移期策略,不是目标架构的启动入口。完成调用方迁移和首请求延迟 +验证后,再由独立变更移除默认预热;届时 runtime 只由显式 workspace intent 或 Session +需求启动。Bridge 已进入 shutdown 后,legacy preheat 明确失败而不是静默成功,使 +调用方不会把一个无法再启动的 runtime 误判为 ready。 + +### 8.4 Draining 与移除 + +Foundation 的 workspace removal activity 已包含 Session 之外的 Bridge 物理 work、 +在途 ensure 和已接纳的 workspace-scoped management operation。非 `force` 移除遇到 +这些活动项返回 `workspace_busy`。进入 `draining` 后,Registry 阻止新的路由解析, +Coordinator 关闭新的 ensure admission;已经解析但尚未开始物理工作的请求以 +`workspace_draining` 失败。移除回滚时一并恢复 admission,提交后的强制清理才终止 +现有 work。 + +Target 还必须把后台 capability 收敛和未终结 operation 纳入 activity。draining +期间收到的 User/global MCP 或 Skills 配置失效要保留为待 reconcile 状态;回滚后立即 +重放,且重放成功前 ensure 不得把旧 Catalog 标成 ready。 + +## 9. Runtime epoch、Catalog 与缓存 + +本节的 Bridge epoch 属于 Foundation;capability status 和 Catalog 规则属于 +**Target**。 + +每次新的 ACP 子进程/Channel 成为当前 runtime 时,Bridge 分配单调递增的 +`runtimeEpoch`,Coordinator 将它绑定到 capability 状态。所有 live 状态和缓存必须 +携带产生它的 epoch。 + +```ts +type CapabilityState = 'not_started' | 'starting' | 'ready' | 'stale' | 'error'; + +interface WorkspaceCapabilityStatus { + state: CapabilityState; + runtimeEpoch?: number; + error?: { code: string; message: string }; +} +``` + +规则: + +1. `ready` 必须来自当前 epoch 完成的 ACP 响应。 +2. 新 epoch 开始时,旧 epoch 的 `ready` 立即变为 `stale`。 +3. 旧 epoch 的 `completed` 不得覆盖新 epoch 的 `not_started` 或空结果。 +4. cache key 至少包含 `workspaceId + capability + runtimeEpoch`;跨 epoch 只能作为 + 明确标记的 stale 展示数据。 +5. 空数组表示当前 epoch 已确认 Catalog 为空,不能兼任“尚未初始化”。 +6. 顶层 `runtimeLive` 只表示当前 Channel 是否存在,不替代 capability 状态。 +7. Bridge 可以暂存带 epoch 的原始响应,但 Coordinator 的投影是对外 capability + 状态唯一来源。 +8. GET status/Catalog 只返回快照;需要 fresh 数据时显式 `ensure` 或领域 runtime + command。 +9. `source: 'config'` 或本地 fallback 可以提供控制面信息,但不能把 runtime + capability 标记为 `ready`。 + +Runtime Catalog 与 Coordinator status 是两个互相校验、不能互相替代的投影: + +- Extensions、MCP、Skills、Tools Catalog 都携带 `initialized`;live 或 cached + 快照携带产生它的 `runtimeEpoch`。MCP/Skills 还显式携带 `source`,其他 Catalog + 的来源由 initialized/epoch 和 Coordinator status 判定; +- Coordinator capability status 携带 `state`、`runtimeEpoch` 和错误;仅 Extension + capability 额外携带 `desiredGeneration`、`appliedGeneration` 和 `appliedEpoch`; +- `appliedEpoch` 是 Coordinator 对 Extension generation 回执的投影,不是 Catalog + 自己的 epoch,也不得由前端用“当前 runtime epoch”猜测; +- 页面只有在 capability 为当前 epoch 的 `ready`、Catalog 已 initialized 且 + Catalog `runtimeEpoch` 与当前 runtime 相等时,才把 Catalog 当作 live;Extensions + 还要求 `desiredGeneration === appliedGeneration` 且 `appliedEpoch` 等于当前 epoch。 + +## 10. Extension generation 与 capability revision + +本节全部为 **Target**。 + +只有具有原子版本化 Store 的 Extension 使用对外可见的 desired/applied generation。 +运行时只有在当前 epoch 明确回执加载了该 generation 后,Coordinator 才能推进 +applied generation。 + +```ts +interface GeneratedCapabilityStatus extends WorkspaceCapabilityStatus { + desiredGeneration: number; + appliedGeneration?: number; + appliedEpoch?: number; +} +``` + +约束: + +1. Extension durable mutation 提交时原子地产生 committed generation,并把它 fan + out 为所有受影响 WorkspaceRuntime 的 desired generation。 +2. Extension reconcile attempt 必须绑定 + `generation + runtimeEpoch + reconciliationRevision`;回执必须携带它实际加载的 + generation,不能在 refresh 完成后重新读取 + store 最新 generation 并猜测已应用值。 +3. `appliedGeneration` 与 runtime snapshot 在同一次成功响应中更新。 +4. 新 epoch 不继承 applied;它必须重新加载 desired state。 +5. `ready` 要求 `appliedGeneration === desiredGeneration`、`appliedEpoch` 等于当前 + epoch,并且存在该 epoch 的实际快照。 +6. desired 前进时,已有 `ready` 立即变为 `starting`(正在 reconcile)或 + `stale`(尚未开始);成功后由 Coordinator 一次性更新 generation 和状态。 +7. Extension generation 前进时,必须同时失效其派生的 Extensions、Skills、Tools、 + MCP、Agents、Hooks、Commands、Context Files、Settings 和 Channels。 +8. Extension generation 在当前 epoch 应用成功后,Coordinator 自动重新 prepare + 此前已经初始化过的 MCP、Skills 和 Tools;从未初始化的能力仍保持按需加载。 +9. 旧 generation、旧 epoch 或旧 reconciliation revision 的迟到成功/失败都不能 + 覆盖当前投影。 + +MCP/Skills 不使用伪造的 desired/applied generation。它们由各 WorkspaceRuntime +Coordinator 维护不对外持久化的单调 capability revision: + +1. durable config mutation 成功后推进相应 revision;cold runtime 标记 + `not_started/stale` 并返回 `deferred`,live runtime 排队 reconcile; +2. reconcile/prepare 捕获 `revision + runtimeEpoch`,只有两者仍为当前值时才可以写 + `ready/error`;较新的 mutation 会使旧尝试失效; +3. 同 capability 的 reconcile 与 runtime mutation 复用 Coordinator 的串行 lane, + 防止 reload、restart、prepare 并发覆盖; +4. Extension generation 前进会同时推进 MCP/Skills/Tools 的 revision,因为 + Extension 可以改变这些有效 Catalog; +5. readiness 由当前 epoch 的 live Catalog 证明,不通过暴露一个并不存在的 + MCP/Skills applied generation 证明。 + +## 11. Ensure、内部 prepare、operation 与 deadline + +### 11.1 Ensure + +`ensure` 是 SDK/UI 唯一的通用 Workspace Runtime 启动命令: + +```http +POST /workspaces/:workspace/runtime/ensure +{} +``` + +primary workspace 使用等价的 `POST /workspace/runtime/ensure`。两个入口都拒绝非空 +body;调用方不选择 capability,也不传 timeout、keepalive 或初始化顺序。 + +#### Foundation(已实现) + +当前 Coordinator 的职责刻意很薄: + +1. 校验 workspace 已准确解析、受信任且未 draining; +2. 调用 Bridge 的物理 preheat/initialize,等待 ACP Channel handshake 完成; +3. 将本次成功转换为 workspace 级 10 分钟 keepalive;并发调用保留最长窗口; +4. 从 Bridge 读取 lifecycle snapshot 并返回。 + +`ensure` 成功只证明 ACP Channel 已完成 initialize 且可由后续 Session、MCP、Skills +等请求复用;它不证明 MCP discovery、Extension refresh 或任一 Catalog 已 ready。 +若同一物理启动正在进行,并发 `ensure` 复用 Bridge 的启动 Promise;每个成功调用都 +从自己的成功时刻续期 keepalive。若启动卡住,Bridge 的绝对启动 deadline 会中止并 +清理该次启动,后续显式 `ensure` 可以发起新的尝试。 + +服务端观察预算为 60 秒。预算耗尽时请求以可重试的 +`runtime_still_starting` 错误结束;底层物理启动仍由独立的绝对启动 +deadline 约束。当前没有 capability 后台收敛 operation,也不会返回 capability +`starting`。`GET /runtime/status` 只观察 lifecycle,不启动或重试 runtime。 + +#### Target(未实现) + +后续 Coordinator 将在同一次 workspace runtime command 中固定准备标准能力 +`extensions -> (mcp, skills, tools)`: + +1. 获取覆盖整个命令的外层 runtime-control lease; +2. 确保 Workspace ACP Runtime 已完成 handshake; +3. 在当前 epoch 加载 Extension desired generation,或捕获 MCP/Skills revision; +4. 初始化标准 capability 集合并更新可轮询状态; +5. 命令完成、失败或安全排空后释放 lease。 + +Target 中 Coordinator 先 prepare Extensions,再并行处理其派生能力;同一 capability +的并发工作合并。HTTP 观察预算耗尽可以先返回 capability `starting`,后台收敛受另一 +个固定 deadline 约束,客户端通过 `/runtime/status` 观察终态。此语义在 capability +Coordinator 落地前不得由 SDK/UI 假设。 + +按 capability 的 prepare 只是 Coordinator 的内部实现,不暴露 HTTP 或 SDK 接口。 +新增 capability 时只修改 Coordinator 的标准能力集合和初始化逻辑。 + +### 11.2 Operation 状态 + +以下为 **Target**: + +```ts +type McpOperationState = + | 'running' + | 'waiting_for_input' + | 'succeeded' + | 'failed'; +``` + +operation 用于 Extension 安装/更新、MCP OAuth 等有副作用、需要交互或不能靠重复 +ensure 表达的工作。一个 operation record 只有一个可写所有者: + +- workspace runtime operation 由对应 Coordinator 所有; +- 全局 Extension 安装等控制面 operation 由全局 controller 所有;它驱动每个受影响 + Coordinator 的 generation reconciliation attempt,但不复制 capability 状态,也不 + 创建另一份同名 runtime operation。 + +`waiting_for_input` 不是终态,仍持有带最大期限的 lease。operation 进入终态后保留 +有限时间供 SDK/UI 查询。 + +Extension controller 保留自己的 `queued/running/waiting_for_input/succeeded/ +succeeded_with_warnings/failed` 状态和 `preparing/committing/reconciling` phase;MCP +runtime operation 使用上面的较小状态集。当前协议不暴露一个虚假的 `timed_out` +终态:若 deadline 后仍不能安全取消,operation 继续保持非终态;安全 drain 后以 +`failed` 和结构化 timeout error 结束。 + +### 11.3 单一 deadline + +**Foundation** 已实现 ACP 物理启动的绝对 deadline。它覆盖 Channel factory 和 +initialize;超时会通过 AbortSignal 请求取消、终止迟到创建的 child,并清除启动 +Promise,使后续 ensure/Session 可以重试。ensure 的 60 秒 HTTP 观察预算不延长这个 +物理 deadline,SDK 使用 62 秒客户端预算为服务端返回预留时间。 + +**Target** 要求每次 capability ensure 或 operation 在入口创建绝对 deadline。每个 +阶段只使用剩余预算,不得让 preheat、discovery、refresh 或每次 UI poll 各自重新 +获得一份完整 timeout。Target ensure 具有调用方观察 deadline 和一次性有界后台收敛 +deadline;前者到达可先返回 `starting`,后者不随 poll 重置。MCP auth 从首次 Bridge +调用到状态 observer 共用同一个 `deadlineAt`。 + +HTTP/SDK 请求超时与 operation deadline 是不同概念: + +- Foundation ensure 的 HTTP 预算结束时返回可重试错误;Target capability ensure + 才返回 `starting`;命令型 operation 返回 `operationId`; +- 请求断开不自动宣告 operation 失败; +- operation 是否继续、取消或超时由其 deadline 和取消策略决定; +- UI 通过 operation/status 查询观察终态。 + +若底层有取消契约,deadline 到达时先请求取消。只有底层任务已经停止、完成必要 +清理,或已被安全地从 ACP 生命周期中分离后,才可以进入 timeout 终态并释放 lease。 +当前 MCP OAuth 没有取消契约,因此 observer deadline 到达不能释放 auth lease 或 +认证全局 lane;具体 safe-drain 语义见第 12 节。 + +### 11.4 持久化提交与激活 + +以下为 **Target**: + +配置接口先提交 durable state,再在同一个扁平 domain result 或 operation result 中 +单独表达 runtime activation。当前 wire contract 不包一层虚构的 `commit` 对象: + +```ts +interface DurableMutationResult { + // name/scope/config/changed 等领域字段;Extensions 可携带 generation + // applied — runtime activation completed for all affected WorkspaceRuntimes. + // deferred — no live runtime; durable commit persisted, activation on next ensure. + // reconciling — durable commit persisted, live runtime reconcile in progress (operationId provided). + // partial — durable commit persisted, activation succeeded for some WorkspaceRuntimes but failed for others. + activation: 'applied' | 'deferred' | 'reconciling' | 'partial'; + operationId?: string; + warnings?: Array<{ workspaceCwd: string; error: string }>; +} +``` + +同步 MCP/Skills mutation 以 HTTP 成功和领域字段表示 durable result;Extensions +mutation 先返回 `operationId`,operation 的 committing phase 成功后,其 result 再 +携带 activation/warnings。提交完成后,即使 activation 超时或失败,也必须返回 +“配置已保存”;客户端超时不能把已经落盘的变更显示成保存失败。 + +## 12. MCP OAuth + +本节为 **Target**,但约束来源于当前 ACP OAuth provider 缺少可靠取消契约这一既有 +事实。 + +OAuth 是 workspace-scoped operation,但 callback listener/port 是 daemon +process-global 资源。锁和路由必须匹配真实资源作用域。 + +当前 ACP OAuth provider 没有取消契约,并使用可能冲突的 process-global callback +资源。因此后续 operation 层采用保守但可证明安全的模型: + +1. Coordinator operation 归属具体 WorkspaceRuntime,并绑定 + `workspaceCwd + serverName + operationId + runtimeEpoch`;同一 workspace/server + 不能并发认证。 +2. daemon 另有一个 process-global authentication lane。任一工作区存在 + `running/waiting_for_input` 的 auth 时,其他工作区或 server 的认证请求明确失败, + 而不是争用 callback listener。 +3. operation 在调用 Bridge 前创建唯一绝对 `deadlineAt`;初始 authenticate RPC 和 + 后续 observer 使用同一个 deadline,不能各自获得一段新的十分钟。 +4. Bridge 在 ACP 返回 `pending` 时,以 `operationId` 记录物理 auth lease,并把实际 + `runtimeEpoch` 返回 Coordinator。`waiting_for_input` 期间最后一个 Session 关闭不 + 得回收 Channel。 +5. observer 只接受 operation 所属 epoch 的 MCP Catalog。新 epoch 的同名 server + 不能完成旧 operation;旧 Channel 退出或 epoch 替换时,旧 operation 失败。 +6. deadline 到达只表示调用方等待预算耗尽。只要 ACP 仍报告 + `authenticationState: pending`,operation 保持 `waiting_for_input`,物理 auth + lease、per-target lane 和 process-global lane 都不得释放。 +7. ACP provider 的 `finally` 在移除 callback listener 和 pending provider 记录后, + 发送带 `operationId + serverName` 的 completion notification。该通知是 Bridge + 释放对应物理 auth lease 的直接排空信号;同 epoch Catalog 中仍存在且明确为 + non-pending 的 server 可以作为兼容佐证。 +8. Catalog 中缺少 server、配置已删除、discovery 已完成或一次状态读取失败,都不是 + provider 已停止的证明,不能据此释放 auth lease 或认证 lane。只有上一步的物理 + 完成证据,或 owning Channel/epoch 已退出,Coordinator 才完成 safe drain,并把 + 超时观察结果写为 `failed/mcp_authentication_timeout`(或 runtime unavailable)。 +9. MCP physical lane 按入队顺序执行。普通任务在入队时捕获当时的 auth barrier:已经 + 排队的 config reload 不受后来创建的 OAuth barrier 反向阻塞;OAuth 之后入队的 + reload/ensure 则必须等待该认证完成 safe drain。这样不会形成“旧 reload 等新 + auth、而新 auth 又等旧 reload”的环形等待。 + +未来只有在 ACP 提供可靠 cancellation,或 callback broker 能按不可伪造 token 完整 +隔离多个认证时,才可以放宽全局串行化;这不是当前架构成立的前提。 + +## 13. 接口与 SDK 边界 + +### 13.1 路由所有权 + +```text +/workspace/config/... 全局/User 配置 owner;部分领域兼容 primary 命名 +/workspace/runtime/... primary WorkspaceRuntime +/workspaces/:workspace/config/... 指定工作区的 Workspace scope 配置 +/workspaces/:workspace/runtime/... 指定 WorkspaceRuntime 的状态与命令 +/sessions/... Session 生命周期和执行 +``` + +以上是 Target 路由分类。Foundation 新增的 runtime 路由只有 primary/qualified +`ensure` 与 `status`;现有 MCP、Skills、Extensions 等领域路由仍按 legacy 契约运行。 + +- config 路由负责安装、CRUD、enable/disable 和 durable commit; +- runtime 路由负责 ensure、status、Catalog、领域 reload/auth 和 operation; +- runtime 命令必须经过 trust gate;敏感 config/runtime mutation 还必须经过 strict + mutation gate。`ensure` 使用普通 daemon mutation admission; +- qualified 路由必须先解析唯一 WorkspaceRuntime,禁止 fallback; +- scope/owner 约束必须由 daemon 路由强制执行;SDK 类型只是调用侧约束,raw HTTP + 客户端不能通过 singular 路由写 Workspace scope; +- 旧路由仅作为兼容 adapter,不得成为新页面的隐藏兜底。 + +Foundation 通过 daemon capability `workspace_runtime` 宣告上述 ensure/status +契约。只有当前所有可路由 WorkspaceRuntime 的 Bridge 都提供 lifecycle snapshot 时 +才发布该 capability;不支持的注入式或旧 Bridge 调用 runtime 路由时返回 +`501 workspace_runtime_not_supported`,服务端不会根据 `isChannelLive` 合成 epoch +或状态。这是迁移兼容边界,不是第二套 lifecycle 实现。 + +Extensions 的边界尤其需要明确: + +- `GET /.../config/extensions` 读取 durable inventory;install/check/update/uninstall 和 + User scope enable/disable 只走全局 config owner;qualified config 路由只写该 + workspace override; +- `GET /.../runtime/extensions` 读取带 epoch 的实际 Catalog,GET 不启动 ACP; +- `POST /.../runtime/ensure` 是管理区域唯一的通用 Runtime 激活入口;页面不选择 + Extension 或其他 capability; +- `/.../config/extensions/refresh` 不得直接调用 Bridge 或启动 runtime。旧 + `/workspace/extensions/refresh` 若暂时保留,只是 legacy Session-centric adapter, + 新 SDK/UI 不调用它;该 legacy-primary adapter 的 operation namespace 与全局 config + owner、qualified workspace owner 都必须隔离。 + +MCP 与 Skills 遵循同一分层: + +- MCP 的 `GET/PUT/DELETE /.../config/mcp/servers` 和 + `POST /.../config/mcp/:server/{enable,disable}` 只读写 durable desired state;User + scope 只允许 singular/global owner,qualified 路由只允许 Workspace scope; +- config inventory 同时返回每个禁用 server 的 User/Workspace owner;页面不能用 + server 定义所在 scope 猜测 `mcp.excluded` 的 owner,尤其不能把 secondary workspace + 的覆盖写进 primary workspace; +- `GET /.../runtime/mcp`、runtime reload/restart、approve/authenticate/clear-auth 和 + runtime operation 属于对应 WorkspaceRuntime。runtime 路由不提供持久化 + enable/disable; +- Skills 的 `GET /.../config/skills` 以及 config install/delete/enable 只使用 + daemon-local inventory 和设置,不查询 live ACP Catalog。global scope 只允许 + singular/global owner,qualified 路由只允许 Workspace scope; +- `GET /.../runtime/skills` 返回当前 epoch 的实际 Skills,`ensure` 负责启动和准备完整 + Runtime, + 包括 Extension 注入内容。只存在于 runtime Catalog、未出现在 config inventory 的 + Extension Skill 是只读项;其来源 Extension 的激活通过 Extension config owner + 管理,Skills 页面不能对它执行 enable/delete。 + +### 13.2 SDK transport + +Workspace config/runtime API 是 daemon REST 控制面,不是 ACP Session method。 +`WorkspaceDaemonClient` 必须显式使用 REST transport,除非 ACP HTTP/WS route table +完整实现同名路由并有等价测试。不能依赖默认 transport 后再遇到 404。 + +Foundation SDK 已提供: + +- primary `ensureWorkspaceRuntime()` / `workspaceRuntimeStatus()`; +- qualified `WorkspaceDaemonClient.ensureRuntime()` / `runtimeStatus()`; +- REST-only transport,以及 62 秒客户端 ensure timeout。 + +Target SDK 还应直接提供: + +- config mutation 及其 durable result/activation; +- Catalog 查询; +- active operation 查询、`getOperation`/`waitForOperation`(命令型长任务)、runtime + status polling(幂等 ensure/reconcile); +- 一个端到端 deadline/AbortSignal,而不是每阶段重置 timeout; +- runtime epoch、source、generation 和 stale 语义的类型。 + +SDK 方法必须按 owner 收窄,而不是依赖服务端 400/404 纠正错误调用: + +- `DaemonClient` 承载全局 Extension install/check/update/uninstall、User scope MCP 和 + global Skill mutation; +- `WorkspaceDaemonClient` 只承载 qualified Workspace config 与对应 runtime API; +- Workspace runtime MCP action 类型只包含 approve/authenticate/clear-auth, + enable/disable 必须调用 config 方法; +- qualified client 不暴露必然被 global-owner gate 拒绝的 Extension mutation。 + +Target 管理区域在进入目标 workspace 或切换 workspace 时调用一次 +`ensureWorkspaceRuntime()`;当前 epoch 已完整 ready 时 Coordinator 直接返回,不重复 +初始化。Extensions、MCP、Skills 页面只读取各自的 config inventory、runtime status +和 runtime Catalog。 +页面不存在 capability-selecting prepare,也不调用 +`refreshWorkspaceConfigExtensions()` 作为新架构 runtime command。 + +UI 不直接调用 Bridge/ACP 兼容接口。 + +WebShell 选择 Session 后,三个管理页必须把该 Session 的规范化 `workspaceCwd` +显式传给 workspace hooks;页面切换工作区时重建本地页面状态。Provider 的 primary +workspace 只作为没有显式 workspace owner 的兼容默认值,不能覆盖活动 Session 的 +workspace,也不能让 qualified action 回落到 primary runtime。 + +#### Web Shell 迁移计划 + +Web Shell 只有在 capability readiness 和 Catalog freshness 契约可用后才切换到 +runtime API。迁移至少满足: + +1. ensure 启动标准 capability 收敛,或明确返回可供页面轮询的 capability 状态; +2. Skills snapshot 记录 live ACP 来源、`runtimeEpoch` 和 capability revision; +3. daemon-local Skills fallback 在新 epoch live 后不能遮蔽当前 ACP Catalog; +4. Extension generation 或 Skills mutation 使同一 epoch 的旧 Skills snapshot 失效; +5. primary 使用 singular ensure,secondary 使用 qualified ensure,任何失败都不回退 + 到其他 workspace; +6. ensure 成功后的 Catalog 读取只接受当前 epoch/revision 的 live 结果,页面无需 sleep + 或依赖缓存 TTL。 + +### 13.3 状态观察 + +以下为 **Target**: + +`GET /runtime/status` 是 capability 收敛的权威观察接口; +`GET /runtime/operations` 返回当前 WorkspaceRuntime 中仍为 +`running/waiting_for_input` 的命令型任务,`GET +/runtime/operations/:operationId` 返回指定任务的权威状态。active collection 和 +by-id 状态都保留 OAuth 的 `deadlineAt` 与 `authUrl`,因此页面刷新或重新进入时可以 +恢复观察,而不重复启动认证。Runtime capability 收敛不通过 Session EventBus 广播; +SDK/UI 只通过 operation/status polling 保证最终收敛,也不会为了观察状态创建隐藏 +Session。 + +## 14. 三个管理页面的目标流程与验收 + +各 capability 的单一所有权和失效条件如下: + +| Capability | Desired owner | 顺序 token | Runtime initializer | Status/cache owner | 主要失效条件 | 下游页面 | +| ---------- | ------------------------------------------------------- | ------------------------------------- | ---------------------------------------- | ------------------ | ----------------------------------------------- | --------------------------- | +| Extensions | daemon Extension Store | Store generation + reconcile revision | 当前 epoch 的 ACP Config refresh | Coordinator | Extension generation、revision、epoch | Extensions、MCP、Skills | +| MCP | User/Workspace settings、Secret、Extension contribution | Coordinator capability revision | 当前 epoch 的 ACP MCP discovery | Coordinator | MCP revision、Extension generation、epoch、auth | MCP、Agent Tool selector | +| Skills | Skill store、settings、Extension contribution | Coordinator capability revision | 当前 epoch 的 ACP Config/Skill discovery | Coordinator | Skills revision、Extension generation、epoch | Skills、Agent editor | +| Tools | settings、Extension contribution | epoch + Extension-derived revision | 当前 epoch 的 ACP ToolRegistry | Coordinator | Extension generation、epoch | Agent editor、Tool selector | + +模块可以保留自己的原始结果缓存,但它们是 Coordinator 状态的输入,不是第二个 +可写状态源。 + +三个页面共享同一个管理区域 Runtime 入口,但 config inventory 与 runtime Catalog +始终是两份明确的数据: + +```text +进入 workspace 的管理区域 -> ensureRuntime()(不传 capability,同 epoch ready 时为 no-op) + -> Coordinator 确保一个 ACP Runtime 并准备标准能力 + -> 各页面读取 config desired state / runtime status / 自己的 Catalog + -> 轮询 operation/status 等待终态 + -> 页面切换复用同一 runtime/epoch;手动刷新仍调用统一 ensure 或领域命令 +``` + +任何页面都不得创建隐藏 Session、选择已有 Session、遍历 Session 进行刷新,或自己 +组合 preheat/initialize/按 capability 启动/reload/poll 状态机。普通 config/status/ +Catalog GET 始终保持只读,不以“页面加载”为理由启动 ACP。 + +### 14.1 Extensions 管理页 + +页面需要同时展示: + +- 控制面:已安装版本、更新状态、全局默认激活、工作区覆盖; +- 运行时:当前工作区实际加载的 Extensions; +- 协调状态:desired/applied generation、operation 和 warning。 + +验收条件: + +1. 无 ACP、无 Session 时可以安装、更新、卸载和修改激活策略。 +2. durable commit 成功后立即显示已保存;cold runtime 返回 `deferred`。 +3. live runtime 的变更进入 reconcile operation,页面显示 + `preparing/committing/reconciling/terminal`,不自行刷新 Session。 +4. 零 Session 的 live runtime 能刷新基础 Config 和 MCP discovery Config。 +5. 有 Session 时同一个 workspace reconciliation 覆盖基础 Config、discovery Config + 和全部 Session Config。 +6. 只有当前 epoch 的 applied generation 等于 desired generation 时显示 ready。 +7. Extension 变更后,Skills、Tools、MCP、Agents、Hooks 等派生 Catalog 一并失效, + 不继续展示旧的 ready 数据。 +8. 全局 Extension 变更对所有受影响工作区分别显示 applied/deferred/warning。 +9. 页面本身只读 config/status/Catalog,不隐式 preheat;管理区域统一调用一次 + `ensureRuntime()`。手动刷新调用同一无参数入口后重读 status/Catalog,不调用 + config refresh,也不传 `extensions` capability。 +10. runtime 行为只能用当前 epoch Catalog 覆盖 config inventory 中的 + `isActive/capabilities/details`;Catalog 未初始化、epoch 不匹配或 applied epoch/ + generation 未收敛时,保留可编辑的 config inventory 并显示 pending/stale。 + +### 14.2 MCP 管理页 + +页面需要区分: + +- 控制面:User/Workspace 配置、enable 状态和 Secret 是否已配置; +- 运行时:discovery 状态、连接、认证、Tools 和 Resources; +- operation:ensure/reload/auth 的进行中与终态。 + +验收条件: + +1. 无 ACP、无 Session 时可以安装/导入、编辑、删除、enable/disable MCP 配置。 +2. qualified workspace config 路由不能修改 User scope。 +3. 配置提交与 runtime reload 分开报告;reload 慢或失败不显示为“保存失败”。 +4. `ensureRuntime()` 在当前 epoch 完成 MCP discovery 后才把 MCP 标为 ready;请求预算耗尽后 + 后台继续,页面通过 operation/status polling 观察终态,而不是只 reload 一次。 +5. Catalog 为空、not_started、starting、stale 和 error 在页面上可区分。 +6. OAuth 在零 Session 时可完成;任一工作区存在进行中的认证时,其他 + workspace/server 的认证请求由 daemon-global lane 明确拒绝,不会抢占 callback。 +7. auth operation 在 waiting_for_input 期间不会因最后一个 Session 关闭而被回收。 +8. ACP 重启后不会用旧 epoch 的 completed cache 跳过 discovery。 +9. User scope 变更会失效并通知所有受影响工作区,不只通知 primary UI。 +10. pending OAuth 对应的 MCP 配置被删除或 reload 后,Catalog 缺少该 server 不会结束 + operation;只有 provider completion notification 或 owning epoch 退出才释放认证 + lane。 +11. 页面刷新、切换后重新进入或客户端等待超时时,通过 active operation collection + 恢复同一 `operationId`、服务端 `deadlineAt` 和 `authUrl`;不得重启认证、延长 + deadline 或把观察失败报告成 daemon 已取消任务。 + +### 14.3 Skills 管理页 + +页面需要区分: + +- 控制面:本地已安装 Skill 和 enable 配置; +- 运行时:当前工作区实际加载的 Skills,包括 Extension 注入内容; +- 数据来源:live、stale cache 或 config/local fallback。 + +验收条件: + +1. 无 ACP、无 Session 时可以列表、安装、卸载和 enable/disable 本地 Skill。 +2. 控制面列表不因 ACP 不存在而失败,也不伪装成 runtime-ready Catalog。 +3. 查询实际有效 Skills 时由管理区域先调用无参数 `ensureRuntime()`,Skills 页面只读 + 当前 epoch Catalog,不创建 Session。 +4. Extension generation 变化会失效 Skills runtime Catalog。 +5. ACP 退出后旧列表明确标记 stale;空列表不会表示 not_started。 +6. 页面不调用 preheat/MCP initialize,也不依赖 Session create/load/close 事件刷新。 +7. config mutation 只以 daemon-local inventory 校验本地 Skill;live Catalog 落后不能让 + 已落盘 Skill 的 enable/delete 返回 not found。 +8. 只由当前 runtime 的 Extension 注入、未出现在 config inventory 的 Skill 可以查看 + 和调用,但 enable/delete 控件保持只读。 +9. global Skill 安装或删除会失效所有已注册工作区的 config inventory cache,cold + workspace 也能立即读到新 durable state。 + +### 14.4 跨页面共同验收 + +- daemon 启动后保持零 Session、零 ACP child,三个页面的 config 管理均可用; +- 三个页面共享一次无参数 ensure,同一工作区只启动一个 ACP Runtime; +- 不同工作区不共享 Workspace scope Config、runtime cache、capability revision 或 + operation 状态;User scope desired state、全局 Extension generation 和 OAuth + authentication lane 是按真实资源作用域刻意共享的, + 但每个 workspace 的 applied generation、epoch 和 auth operation 仍严格隔离; +- 未知工作区不回退 primary;未信任工作区仍可读取 daemon-local config inventory, + 但 runtime command 和敏感 Workspace scope mutation 明确失败,global config owner + 不受 primary workspace trust 影响; +- 最后一个 Session 关闭不影响页面正在进行的 operation; +- 最后一个物理 lease 释放后,默认或 `0` 立即回收 ACP child;配置正值时,在 timeout + 窗口内再次打开管理页面会复用同一 runtime/epoch; +- 显式 `ensure` 成功后至少保活十分钟,使紧随其后的状态与 Catalog 读取能够观察到 + 已初始化的 runtime;再次 `ensure` 会续期该窗口; +- 外层 runtime-control lease 连续覆盖一次 ensure;回收只能在同一 epoch 完成所请求的 + RPC 且 lease 排空后执行; +- 非 `force` workspace removal 会把零 Session 的 ensure、Catalog、reconcile、OAuth + 等 runtime work 计为 busy;进入 draining 后的新管理命令明确失败; +- Session create/load/close 不再作为任何管理 capability 的初始化或失效信号; +- 敏感 config/runtime mutation 经过 strict mutation gate。`ensure` 与 Session + 创建使用相同的普通 daemon admission:无 token 的 loopback 开发模式可调用; + 配置 token、`--require-auth` 或非 loopback 部署仍要求 bearer auth。 + +## 15. 渐进落地顺序 + +### 阶段一:收口所有权(Foundation 已完成) + +- 让 WorkspaceRuntime 聚合成为唯一运行时边界; +- Bridge 收口 channel、epoch、物理 lease 和 idle 回收; +- Coordinator 只投影 runtime lifecycle,并提供无 capability 参数的 ensure/status; +- 将 Coordinator 纳入 workspace drain、removal 和 daemon shutdown; +- 保持现有领域 mutation 路由不变,不在 foundation 中引入 MCP、Extension 或 Skills + 专属状态。 + +### 阶段二:收口状态与 operation(未开始) + +- Coordinator 增加 capability、Extension generation、MCP/Skills revision 和 + operation 投影; +- 所有 capability 状态绑定 epoch; +- 清除跨 epoch ready 合并; +- 将 Extension desired/applied generation 更新改为同一 runtime 回执,MCP/Skills 使用 + revision + epoch 丢弃迟到结果; +- 使用绝对 deadline;幂等收敛由 status、命令型长任务由 operation 暴露终态; +- 将 durable result 与 activation 响应拆开,不发明嵌套 commit wire object; +- 让 MCP OAuth 使用 daemon-global authentication lane,并在无取消契约时 safe drain。 + +### 阶段三:迁移模块和 SDK(部分完成) + +- 已完成:Workspace SDK 的 foundation ensure/status 对 runtime 路由显式使用 REST; +- 待完成:SDK config/runtime Catalog、operation 和 capability status 契约; +- 待完成:Skills snapshot 增加 live source、runtime epoch 和 revision freshness; +- 待完成:Web Shell 接入 primary/qualified ensure; +- Extensions、MCP、Skills 页面迁移到统一 config/runtime/operation 模型; +- 管理区域以无参数 runtime ensure 启动完整标准能力,各页面不再传 capability; +- Extensions 页面从 runtime Catalog 取得实际状态,不从 config refresh 调 Bridge; +- User scope 状态失效覆盖所有受影响工作区;可选事件也要 fan out; +- 新页面不再调用 legacy preheat、initialize 或 Session API。 + +### 阶段四:清理兼容入口(未开始) + +- 在所有调用方迁移并有回归测试后,标记旧 preheat、MCP initialize 和 + `/workspace/extensions/refresh` 路由 deprecated; +- 在调用方迁移并验证首请求延迟后,移除 production startup preheat 默认策略; +- 删除重复的前端轮询和模块级可写状态投影; +- 根据 Bridge 剩余职责决定是否重命名,不为了命名本身扩大改动。 + +## 16. 验证策略 + +### 16.1 Foundation 已验证范围 + +Foundation 的自动化验证覆盖: + +1. lifecycle 五态、epoch 单调、所有已接入物理 work lease 和 idle 回收; +2. primary/qualified ensure/status 的 scope、trust、draining、removed、501 兼容行为 + 以及禁止 primary fallback; +3. 并发 ensure、10 分钟续期、不同 keepalive 取最长窗口和启动失败后的重试; +4. Channel factory/initialize 绝对启动 deadline、AbortSignal、迟到 child 清理; +5. OAuth pending lease、期限到达前的最终状态确认,以及无法证明完成时通过 owning + Channel 退出安全排空; +6. production 默认预热受信任 primary、不预热 untrusted primary,以及显式 + `preheatBridge: false` 的 opt-out; +7. SDK REST 路由与 timeout。 + +此外已通过相关单元测试、lint、build 和 typecheck。仍建议在合并前补充或人工执行 +真实 ACP child E2E,验证进程级时序而不只验证 mock 契约。 + +### 16.2 Target 完整迁移验证 + +后续实现不能只验证单个路由成功,至少覆盖: + +1. **零 Session E2E**:分别完成 Extensions、MCP、Skills 页面完整管理流程。 +2. **epoch 重建**:准备成功后通过显式正值 idle timeout、child crash 或 restart 重建, + 确认旧 cache 只为 stale,新 runtime 重新初始化。 +3. **并发**:同 workspace 并发 ensure、MCP mutation/reconcile 串行化;先入队 + reload 与后创建 OAuth 不互锁,OAuth 后入队的 reload 等待 safe drain;跨 + workspace OAuth 全局拒绝/串行、User scope fan-out。 +4. **慢路径**:ACP 启动、MCP discovery、Extension reconcile 超过 HTTP 等待预算后, + capability status 或命令型 operation 仍到达正确终态。 +5. **持久化失败矩阵**:commit 成功但 activation 失败、cold deferred、部分工作区 + reconcile 失败。 +6. **生命周期**:最后 Session 关闭、waiting_for_input、在途 Catalog RPC、显式正值 + idle 窗口复用和 daemon shutdown。 +7. **隔离与安全**:unknown/draining/removed 均不 fallback;untrusted workspace 的 + runtime command 必须通过 trust gate,敏感 Workspace scope mutation 还必须通过 + strict mutation gate。`ensure` 遵循普通 daemon auth admission;daemon-local + config GET 仍可读,global config owner 不受 primary trust 影响。 +8. **SDK transport**:REST、ACP HTTP/WS 模式下 Workspace client 都不会把 daemon + runtime 路由误发为 ACP method。 +9. **连续 lease**:未配置 idle timeout 或显式配置 `0` 时,runtime work 排空后立即回收; + 显式正值时按空闲窗口回收。兼容 preheat 保留旧资源语义;显式 `ensure` 额外登记 + 可续期的 10 分钟 workspace 保活窗口。Capability RPC 与最终状态投影不跨 epoch, + 物理回收只发生在外层 runtime-control lease 释放后。 +10. **OAuth 排空**:认证 pending 时删除配置或 reload,Catalog 缺失不释放 auth + lease;completion notification、listener 清理、operation 终态和全局 lane 释放按 + 此顺序发生。 +11. **Draining/removal**:零 Session runtime work 使非强制移除返回 busy;已解析请求在 + drain 后不能启动新物理工作;draining 期间的 global config invalidation 在回滚后 + 重放,重放失败时后续 ensure 继续重试而不接受旧 Catalog。 +12. **控制面独立性**:live 但 Catalog 落后时,本地 Skill 仍可通过 config API + enable/delete;global Skill mutation 会失效其他 workspace 的 config cache; + runtime-only Extension Skill 保持只读。 +13. **SDK owner contract**:每个 typed mutation 都有与其 owner 对应的可达路由;runtime + MCP 类型不接受 enable/disable,qualified client 不暴露 global-only Extension + mutation。 +14. **管理页 owner contract**:在 primary 和 secondary workspace 各打开一次三个管理 + 页;每个 workspace 只调用无参数 ensure,所有 runtime/status/Catalog/Workspace + scope mutation 都命中活动 Session 的 workspace,User/global mutation 命中全局 + config owner,且没有 capability 参数或 singular primary runtime fallback。 + +## 17. 实施原则 + +1. 优先修正 ownership,再修正局部 symptom。 +2. 一个事实只有一个可写所有者;其他层只保存带 epoch/generation/revision 的只读 + 投影。 +3. 不在 daemon 和 ACP 两处重复构造完整 Config。 +4. 配置提交、运行时应用和 UI 观察是三个明确阶段。 +5. 所有阶段使用入口计算的绝对 deadline;HTTP 超时不等于 operation 失败;无取消 + 契约时 deadline 也不等于可以释放底层资源。 +6. 每增加一个 capability,必须列出初始化者、缓存所有者、失效条件和所有下游调用方。 +7. 优先复用现有 Bridge 和 ACP 方法,但不保留 Session-centric 的所有权语义。 +8. Extension durable commit 不因后续 reconcile 失败而回滚;用 generation、warning + 和 activation 状态表达结果。MCP/Skills 不暴露不存在的 generation,而使用内部 + revision 保证最新尝试获胜。 +9. 禁止通过隐藏 Session、任意活跃 Session 或 primary fallback 完成工作区管理。 +10. 迁移以第 14 节页面行为为完成标准,不以新增路由或类名为完成标准。 diff --git a/docs/developers/daemon/00-index.md b/docs/developers/daemon/00-index.md index c27dd7966e2..1d913a0ebe7 100644 --- a/docs/developers/daemon/00-index.md +++ b/docs/developers/daemon/00-index.md @@ -63,7 +63,7 @@ Pick the path that matches your goal: ## Glossary - **ACP** - Agent Client Protocol. JSON-RPC over stdio spoken between the daemon bridge and the ACP child process. This is not the HTTP protocol that clients use against the daemon. -- **ACP child** - the `qwen --acp` child that hosts one workspace's agent runtime. Production attempts to preheat the primary bridge and retries on first use after failure; a trusted secondary starts its child on demand, while an untrusted secondary does not. The owning bridge multiplexes sessions and clients onto that child. +- **ACP child** - the `qwen --acp` child that hosts one workspace's agent runtime. Production attempts to preheat the trusted primary child for compatibility; trusted secondaries start on their first runtime command or Session, and untrusted workspaces do not start ACP. The owning bridge multiplexes sessions and clients onto that child. - **acp-bridge** - the `@qwen-code/acp-bridge` package (`packages/acp-bridge/`). Owns session multiplexing, the permission mediator, the event bus, and the channel factory. - **BridgeClient** - `packages/acp-bridge/src/bridgeClient.ts`. Wraps one ACP `ClientSideConnection`, and handles `requestPermission`, `sendPrompt`, and `cancelSession`. - **Channel factory** - pluggable strategy for spawning or attaching to an ACP child. The default `spawnChannel` runs `qwen --acp` as a subprocess; `inMemoryChannel` runs it in-process for tests. diff --git a/docs/developers/daemon/03-acp-bridge.md b/docs/developers/daemon/03-acp-bridge.md index 6a41e54e41c..5e7b058c2ec 100644 --- a/docs/developers/daemon/03-acp-bridge.md +++ b/docs/developers/daemon/03-acp-bridge.md @@ -4,7 +4,7 @@ `packages/acp-bridge/` owns the boundary between the daemon's HTTP layer and the ACP child process. It is consumed by `packages/cli/src/serve/` (the `qwen serve` daemon) and was extracted in #4175 F1 step 3 so future consumers (`channels/base/AcpBridge.ts`, the VS Code IDE companion) can use the same bridge core without reaching into the CLI package. -Each active `WorkspaceRuntime` owns one `HttpAcpBridge` instance. Production attempts to preheat the primary bridge and retries on first use after failure. A trusted secondary opens its `AcpChannel` and starts its child on demand; an untrusted secondary cannot start ACP. Within the runtime, the bridge provides multiplexed sessions over the channel, per-session `EventBus`es, a `MultiClientPermissionMediator`, a `BridgeFileSystem` adapter, and ACP-oriented helpers (`spawnOrAttach`, `loadSession`, `resumeSession`, `sendPrompt`, `cancelSession`, `respondToPermission`, plus extMethod RPCs for workspace status and MCP restart). Bridges and children are never shared across workspace runtimes. +Each active `WorkspaceRuntime` owns one `HttpAcpBridge` instance. Production attempts to preheat the trusted primary child for compatibility; trusted secondaries open their `AcpChannel` on the first runtime command or Session, and untrusted workspaces cannot start ACP. Within the runtime, the bridge provides multiplexed sessions over the channel, per-session `EventBus`es, a `MultiClientPermissionMediator`, a `BridgeFileSystem` adapter, and ACP-oriented helpers (`spawnOrAttach`, `loadSession`, `resumeSession`, `sendPrompt`, `cancelSession`, `respondToPermission`, plus extMethod RPCs for workspace status and MCP restart). Bridges and children are never shared across workspace runtimes. ## Responsibilities @@ -47,7 +47,7 @@ Each active `WorkspaceRuntime` owns one `HttpAcpBridge` instance. Production att | `mediator` | `MultiClientPermissionMediator` | One per bridge instance. | | Constants | — | `DEFAULT_INIT_TIMEOUT_MS = 10_000`, `MCP_RESTART_TIMEOUT_MS = 300_000`, `DEFAULT_MAX_SESSIONS = 32`, `MAX_EVENT_RING_SIZE = 1_000_000`, `DEFAULT_PERMISSION_TIMEOUT_MS = 0`, `DEFAULT_MAX_PENDING_PER_SESSION = 64`. | -**`isDying` invariant**: any teardown path must set `ChannelInfo.isDying = true` synchronously **before** awaiting `channel.kill()`. `ensureChannel` treats a dying channel as absent and spawns a fresh one. Without this flag a concurrent `spawnOrAttach` arriving during the SIGTERM grace window (up to 10s) would attach to a transport about to close and the caller's sessionId would 404 on every follow-up. **Set sites** (must keep in sync): `ensureChannel` (initialize failure + late-shutdown re-check), `doSpawn` (newSession failure on empty channel), `killSession` (last session leaving), `shutdown` (bulk). +**`isDying` invariant**: any teardown path must set `ChannelInfo.isDying = true` synchronously **before** awaiting `channel.kill()`. `ensureChannel` treats a dying channel as absent and spawns a fresh one. Without this flag a concurrent `spawnOrAttach` arriving during the SIGTERM grace window (up to 10s) would attach to a transport about to close and the caller's sessionId would 404 on every follow-up. Teardown includes initialization failures, failed empty-channel spawns, workspace idle expiry, and shutdown; closing the last Session only releases its lease and schedules the workspace idle policy. **`channelInfo` retention invariant**: do **not** clear `channelInfo` when setting `isDying = true`. `killAllSync` must still find the channel during the SIGTERM grace window to fire SIGKILL on `process.exit(1)`. `aliveChannels` holds the dying entry until `channel.exited` fires. @@ -177,7 +177,7 @@ sequenceDiagram ## State & Lifecycle - Bridge construction is synchronous. A caller may preheat the channel before the first session; otherwise the first `spawnOrAttach` cold-starts the ACP child. A failed preheat leaves first use free to retry. -- `defaultEntry` lives for the lifetime of the bridge under `sessionScope: 'single'`; the channel reaps when `sessionIds.size === 0` (after `killSession`) AND `isDying` flips true. +- `defaultEntry` is the reusable logical Session under `sessionScope: 'single'`. Session close removes only its Session lease. After all Session, restore, workspace-control, discovery, authentication, and runtime-operation work drains and no explicit-ensure keepalive window is pending, an omitted or zero `channelIdleTimeoutMs` reaps the child immediately; a positive value — or an active keepalive window — delays reaping. - `MAX_EVENT_RING_SIZE = 1_000_000` is a soft upper bound on `BridgeOptions.eventRingSize` to catch operator typos before ~500 MB per-session OOMs. - `DEFAULT_PERMISSION_TIMEOUT_MS = 0` lets human permissions and questions wait indefinitely by default. `permissionResponseTimeoutMs` enables a wall-clock cap when operators need one; voter cancellation, session cancellation, and shutdown remain available without it. - `DEFAULT_MAX_PENDING_PER_SESSION = 64` mirrors `DEFAULT_MAX_SUBSCRIBERS`; excess `requestPermission` calls resolve as cancelled with a stderr warning. @@ -214,7 +214,7 @@ sequenceDiagram | `permissionPolicy` | from `settings.json`'s `policy.permissionStrategy` | One of `first-responder` / `designated` / `consensus` / `local-only`. | | `permissionConsensusQuorum` | from `settings.json` | N for consensus policy. | | `permissionAudit` | `createNoOpPermissionAuditPublisher()` | Wire to `PermissionAuditRing` for the audit trail. | -| `channelIdleTimeoutMs` | `0` | Keep the ACP child alive for this many milliseconds after the last session closes. | +| `channelIdleTimeoutMs` | `0` | Auto-reap delay after all workspace runtime work drains; unset or `0` reaps immediately. | Timed-out restores are not cancellable in the current ACP SDK. The bridge therefore keeps a settlement fence and capacity admission until the real request settles or its transport closes. A late result is closed exactly once and never registered. Cleanup uncertainty quarantines only fresh session work on that workspace; existing session and workspace-control traffic continues until the channel drains and is recycled. diff --git a/docs/developers/daemon/05-mcp-transport-pool.md b/docs/developers/daemon/05-mcp-transport-pool.md index 65c1db8193f..c5bbe0fa51f 100644 --- a/docs/developers/daemon/05-mcp-transport-pool.md +++ b/docs/developers/daemon/05-mcp-transport-pool.md @@ -2,7 +2,7 @@ ## Overview -`McpTransportPool` (`packages/core/src/tools/mcp-transport-pool.ts`) is the F2 (#4175 commit 5) workspace-scoped pool: multiple ACP sessions inside one runtime share one transport per unique `(serverName + configFingerprint)` tuple, instead of each spawning its own MCP child process. When pool mode is enabled, every started ACP child owns an independent pool (`QwenAgent.mcpPool`). Production attempts to preheat the primary child and retries on first use after failure; a trusted secondary starts its child on demand, while an untrusted secondary starts neither. The pool is constructed once at agent startup with the runtime's bootstrap `Config` and survives session lifecycles. Entries reference-count session attaches and close after a configurable grace period when the reference count reaches zero. +`McpTransportPool` (`packages/core/src/tools/mcp-transport-pool.ts`) is the F2 (#4175 commit 5) workspace-scoped pool: multiple ACP sessions inside one runtime share one transport per unique `(serverName + configFingerprint)` tuple, instead of each spawning its own MCP child process. When pool mode is enabled, every started ACP child owns an independent pool (`QwenAgent.mcpPool`). Production attempts to preheat the trusted primary child for compatibility; trusted secondaries start on demand, and an untrusted workspace starts neither the child nor its pool. The pool is constructed once at agent startup with the runtime's bootstrap `Config` and survives session lifecycles. Entries reference-count session attaches and close after a configurable grace period when the reference count reaches zero. It is the main mechanism that prevents a multi-session daemon from forking one copy of every MCP server per session. diff --git a/docs/developers/daemon/08-session-lifecycle.md b/docs/developers/daemon/08-session-lifecycle.md index a78f0a19e92..6172ff01e82 100644 --- a/docs/developers/daemon/08-session-lifecycle.md +++ b/docs/developers/daemon/08-session-lifecycle.md @@ -320,19 +320,26 @@ normally; it must not trigger a resync loop. ### ACP Child Preheat -`bridge.preheat()` warms the ACP child process before the first session so that -the first real session avoids cold-start latency. It pairs with -`channelIdleTimeoutMs`, which keeps the ACP child alive after the last session -closes, and skip-relaunch behavior, which reuses an already idle child when a -new session arrives. +`bridge.preheat()` remains available to explicit embedders, but `qwen serve` +also attempts to preheat the trusted primary child after startup for +compatibility. A failed preheat is non-fatal and the next runtime command or +Session retries; trusted secondaries start on first use. The Workspace Runtime +owns the child while work is active. After all Session and management leases +drain, an omitted or zero `channelIdleTimeoutMs` keeps the legacy behavior and +reaps the child immediately. A positive value keeps the idle child reusable +until the configured timeout expires. The public Workspace Runtime `ensure` +command adds a renewable ten-minute workspace lease; each successful call +resets that window, including when the channel was already live. ## Configuration - `BridgeOptions.maxSessions` (default 32) — cap. - `BridgeOptions.sessionScope` (default `'single'`; optional `'thread'`). -- `BridgeOptions.initializeTimeoutMs` (default 10s) — ACP `initialize` handshake. +- `BridgeOptions.initializeTimeoutMs` (default 10s) — ACP child startup + deadline (Channel factory + `initialize` handshake) and default request + timeout. - `BridgeOptions.sessionRestoreTimeoutMs` (default 60s) — ACP `loadSession` / `unstable_resumeSession` deadline. Defaults to 60s; an explicitly configured initialize timeout can raise it, but never lower it. -- `BridgeOptions.channelIdleTimeoutMs` (default 0; reap the ACP child immediately). +- `BridgeOptions.channelIdleTimeoutMs` (unset or `0` reaps immediately after runtime work drains; a positive value delays reaping). - Capability tags: `session_create`, `session_id_override`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume` (deprecated alias), `session_list`, `session_info`, `session_close`, `session_metadata`, `session_set_model`, `client_identity`, `client_heartbeat`, `session_recap`, `session_generation`, `session_btw`, `session_context_usage`, `session_tasks`, `session_monitor_tool_correlation`, `session_stats`, `session_lsp`, `session_status`, `non_blocking_prompt`. ### Stateless generation (`session_generation` capability tag) diff --git a/docs/developers/daemon/17-configuration.md b/docs/developers/daemon/17-configuration.md index ed8d89358fb..9181a28b48e 100644 --- a/docs/developers/daemon/17-configuration.md +++ b/docs/developers/daemon/17-configuration.md @@ -39,8 +39,8 @@ This page collects every setting that affects the `qwen serve` daemon and its ad | `--web` / `--no-web` | boolean | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and `/session/:id` document navigations). These entry points are mounted before `bearerAuth`; API calls still follow configured bearer auth or trusted-loopback authority. `--no-web` leaves the daemon API-only. | | `--prompt-deadline-ms ` | positive integer | unset | Server-side prompt wallclock limit in ms. Timeout aborts and returns an error. | | `--writer-idle-timeout-ms ` | positive integer | unset | Per-SSE-connection idle timeout in ms. The daemon closes the SSE connection when no event is sent for this duration. | -| `--channel-idle-timeout-ms ` | non-negative integer | `0` | How long to keep the ACP child alive after the last session closes. `0` means reclaim immediately. | -| `--initialize-timeout-ms ` | positive integer | `10000` | ACP child request timeout, including the initialize handshake (ms). | +| `--channel-idle-timeout-ms ` | non-negative integer | `0` | ACP child auto-reap delay after the last session and workspace operation drain. Unset or `0` reaps immediately. | +| `--initialize-timeout-ms ` | positive integer | `10000` | ACP child startup deadline (channel factory + initialize handshake) and default request timeout (ms). | | `--session-restore-timeout-ms ` | positive integer | `60000` | ACP session load/resume timeout (ms). When this flag is omitted, an explicitly supplied initialize timeout raises the budget but never lowers it below the default. | | `--permission-response-timeout-ms ` | non-negative integer | `0` | Shared wall-clock timeout for ordinary permission and `ask_user_question` responses. `0` or an omitted flag waits indefinitely; a positive value enables the timer. | | `--session-reap-interval-ms ` | non-negative integer | `60000` | Session reaper scan interval; `0` disables it. | @@ -128,7 +128,7 @@ The daemon constructs each workspace runtime from that workspace's merged settin | `enableSessionShell` | Enables session shell execution; bearer auth or trusted-loopback authority and a session-bound client id are still required. | | `promptDeadlineMs` | Prompt wallclock limit. | | `writerIdleTimeoutMs` | SSE writer idle timeout. | -| `channelIdleTimeoutMs` | How long to keep the ACP child warm after the last session closes. | +| `channelIdleTimeoutMs` | ACP child auto-reap delay after runtime work drains; unset or `0` reaps immediately. | | `initializeTimeoutMs` | ACP child request timeout, including the initialize handshake. | | `sessionRestoreTimeoutMs` | ACP session load/resume timeout. Precedence: explicit restore value; otherwise an explicit initialize value raises the 60000 default but never lowers it; otherwise 60000. | | `sessionReapIntervalMs` | Session reaper scan interval. | @@ -150,7 +150,7 @@ The daemon constructs each workspace runtime from that workspace's merged settin | `statusProvider` | Daemon-host preflight cells. | | `childEnvOverrides` | Per-handle environment additions or removals. | | `externalToolGuard` | Optional daemon-side handler for the private child-to-parent prepare RPC. The bridge validates channel ownership and the active Prompt before and after it calls the handler. | -| `channelIdleTimeoutMs` | How long to keep the ACP child alive after the last session closes, in ms; default `0`. | +| `channelIdleTimeoutMs` | ACP child auto-reap delay after runtime work drains; unset or `0` reaps immediately. | ## Important defaults diff --git a/docs/developers/daemon/20-quickstart-operations.md b/docs/developers/daemon/20-quickstart-operations.md index 35b40615c70..83f4540ac57 100644 --- a/docs/developers/daemon/20-quickstart-operations.md +++ b/docs/developers/daemon/20-quickstart-operations.md @@ -60,7 +60,7 @@ QWEN_SERVER_TOKEN=secret \ # 11. Prompt deadline + SSE idle timeout qwen serve --prompt-deadline-ms 300000 --writer-idle-timeout-ms 600000 -# 12. Keep the ACP child warm after the last session closes +# 12. Keep an idle ACP child reusable for 60s after work drains qwen serve --channel-idle-timeout-ms 60000 # 13. Enable HTTP rate limiting @@ -98,8 +98,8 @@ The CLI is defined in **`packages/cli/src/commands/serve.ts`**: | `--allow-private-auth-base-url` | boolean | `false` | - | Allows localhost / private-network auth provider `baseUrl` installation. Use only for trusted local development. | | `--prompt-deadline-ms ` | number | none | - | Server-side prompt wallclock limit in ms; timeout aborts the prompt. | | `--writer-idle-timeout-ms ` | number | none | - | Per-SSE-connection idle timeout in ms. | -| `--channel-idle-timeout-ms ` | number | `0` | - | Keeps the ACP child alive after the last session closes. `0` means reclaim immediately. | -| `--initialize-timeout-ms ` | number | `10000` | - | ACP child request timeout, including the initialize handshake (ms). | +| `--channel-idle-timeout-ms ` | non-negative integer | `0` | - | ACP child auto-reap delay after all Session and management work drains. Unset or `0` reaps immediately. | +| `--initialize-timeout-ms ` | number | `10000` | - | ACP child startup deadline (channel factory + initialize handshake) and default request timeout (ms). | | `--session-reap-interval-ms ` | number | `60000` | - | Session reaper scan interval. `0` disables it. | | `--session-idle-timeout-ms ` | number | `1800000` | - | Disconnected-session idle timeout. `0` disables it. | | `--rate-limit` / `--no-rate-limit` | boolean | env / off | - | Enables or disables per-tier HTTP rate limiting. | diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 6584c419eba..6e696247851 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -214,7 +214,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'session_branch', 'rate_limit', 'workspace_reload', 'channel_delivery', 'multi_workspace_sessions', 'multi_workspace_session_rewind', 'multi_workspace_session_shell', 'persistent_workspace_registration', - 'workspace_display_name', + 'workspace_display_name', 'workspace_runtime_removal', 'workspace_runtime', 'workspace_qualified_rest_core', 'workspace_qualified_voice', 'workspace_qualified_memory', 'extension_management_v2', 'extension_git_credentials', 'extension_local_path_install', @@ -236,6 +236,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design `workspace_runtime_removal` advertises synchronous hot removal through `DELETE /workspaces/:workspace`. Capability workspace entries add optional `removable`; only rows with `removable: true` may be removed. Removal also forgets every persistent registration alias for the runtime, but never deletes files, settings, transcripts, or archives. +`workspace_runtime` advertises `GET /workspace/runtime/status`, `POST /workspace/runtime/ensure`, and their `/workspaces/:workspace/runtime/...` equivalents. `ensure` accepts no capability selection: it starts or reuses the selected trusted workspace's ACP runtime and returns its lifecycle state and monotonic runtime epoch. The primary routes are owned only by the primary runtime; qualified routes resolve only the selected registered runtime and never fall back. A successful ensure renews the runtime's ten-minute keepalive window. Status is read-only and never starts the child. Concurrent ensures share one physical startup. Startup in progress or failure returns retryable `503 runtime_still_starting` or `503 runtime_initialization_failed`. The capability is advertised only when all active runtime bridges provide the authoritative lifecycle snapshot; a selected legacy injected bridge returns `501 workspace_runtime_not_supported` instead of a guessed state or epoch. + `session_load` and `session_resume` advertise the explicit-restore routes (`POST /session/:id/load` and `POST /session/:id/resume`). Older daemons return `404` for these paths, so SDK clients should pre-flight `caps.features` before calling. `unstable_session_resume` is still advertised as a deprecated alias for compatibility with SDKs that shipped while the underlying ACP method was named `connection.unstable_resumeSession`; new clients should gate on `session_resume`. `limits.sessionRestoreTimeoutMs`, when present, is the daemon's wall-clock budget for the underlying ACP `loadSession` / `unstable_resumeSession` request. It is an additive v1 field. The TypeScript SDK gives the daemon 10 seconds of client headroom, and the WebUI watchdog gives it 15 seconds; clients talking to an older daemon should use 70 seconds and 75 seconds respectively. @@ -513,6 +515,7 @@ operator diagnostic snapshot documented below. | `scratch_workspace_registration` | managed scratch workspace creation is available — a runtime factory, a validated managed scratch root, and runtime disposal are wired, and every managed runtime respects the scratch root boundary. | | `workspace_runtime_removal` | removable dynamic or persistence-restored secondary runtimes can be drained and removed through the management route. | | `native_directory_picker` | the daemon host can open a native OS directory picker (`osascript` on macOS, PowerShell on Windows, `zenity` on a Linux host with a display). Headless hosts omit the tag so clients hide the Browse affordance instead of surfacing a guaranteed picker failure. | +| `workspace_runtime` | every active workspace bridge provides an authoritative runtime lifecycle snapshot; mixed or legacy injected bridges omit the tag. | | `workspace_qualified_acp` | ACP HTTP and multi-workspace runtimes are active, so the plural ACP endpoint can select a secondary runtime. | | `workspace_qualified_voice` | multi-workspace runtimes and the shared ACP/Voice WebSocket listener are active, so every workspace-qualified Voice modality is reachable for a secondary runtime. | | `workspace_qualified_memory` | ACP HTTP and multi-workspace runtimes are active, so workspace-qualified managed-memory routes can select a per-workspace task lane for remember, forget, and dream operations. | diff --git a/docs/users/qwen-serve-deploy-local.md b/docs/users/qwen-serve-deploy-local.md index c58df3530c0..7d8bd2a292f 100644 --- a/docs/users/qwen-serve-deploy-local.md +++ b/docs/users/qwen-serve-deploy-local.md @@ -40,9 +40,9 @@ are logged rather than restoring the removed runtime. Runtime isolation covers cwd, environment overlay, filesystem/trust boundary, workspace services, bridge, Voice lease state, channel worker, and the ACP/MCP -resource boundary. Production attempts to preheat the primary ACP child and -retries on first use after failure; trusted secondaries start theirs on demand, -and untrusted secondaries do not start ACP. +resource boundary. Production attempts to preheat the trusted primary ACP child +for compatibility; trusted secondaries start on their first runtime-backed +command or Session, and untrusted workspaces do not start ACP. Authentication, HTTP rate limits, listener and Voice admission caps, total-session admission, metrics, shutdown, and the process fault radius remain daemon-global. Run separate daemons when those process-level boundaries must be diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 5af5fbe9a2a..1a286915ea9 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -426,6 +426,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_display_name', 'workspace_runtime_removal', ...(nativeDirectoryPickerAtBoot ? ['native_directory_picker'] : []), + 'workspace_runtime', 'workspace_qualified_rest_core', 'extension_management_v2', 'extension_git_credentials', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 511f17d9c58..773b5726159 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -71,6 +71,7 @@ import type { ChannelFactory } from './channel.js'; import type { BridgeOptions, BridgeFreshSessionAdmissionContext, + BridgeRuntimeEpochSource, BridgeTelemetry, } from './bridgeOptions.js'; import { createInMemoryChannel } from './inMemoryChannel.js'; @@ -4192,14 +4193,19 @@ describe('createAcpSessionBridge', () => { it('bounds a hung session extension refresh', async () => { vi.useFakeTimers(); const refreshGate = deferred>(); - const handle = makeChannel({ + const stalledHandle = makeChannel({ extMethodImpl: async (method) => method === SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh ? await refreshGate.promise : {}, }); + const replacementHandle = makeChannel(); + const channelFactory = vi + .fn() + .mockResolvedValueOnce(stalledHandle.channel) + .mockResolvedValueOnce(replacementHandle.channel); const bridge = makeBridge({ - channelFactory: async () => handle.channel, + channelFactory, }); try { await bridge.spawnOrAttach({ workspaceCwd: WS_A }); @@ -4208,31 +4214,33 @@ describe('createAcpSessionBridge', () => { await vi.advanceTimersByTimeAsync(30_000); await expect(refresh).resolves.toEqual({ refreshed: 0, failed: 1 }); + expect(stalledHandle.killed).toBe(true); - const retry = bridge.refreshExtensionsForAllSessions(); + await expect(bridge.refreshExtensionsForAllSessions()).resolves.toEqual({ + refreshed: 0, + failed: 0, + }); expect( - handle.agent.extMethodCalls.filter( + stalledHandle.agent.extMethodCalls.filter( (call) => call.method === SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, ), ).toHaveLength(1); - await vi.advanceTimersByTimeAsync(30_000); - await expect(retry).resolves.toEqual({ refreshed: 0, failed: 1 }); - refreshGate.resolve({}); - await vi.advanceTimersByTimeAsync(0); + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); await expect(bridge.refreshExtensionsForAllSessions()).resolves.toEqual({ refreshed: 1, failed: 0, }); + expect(channelFactory).toHaveBeenCalledTimes(2); expect( - handle.agent.extMethodCalls.filter( + replacementHandle.agent.extMethodCalls.filter( (call) => call.method === SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, ), - ).toHaveLength(2); + ).toHaveLength(1); } finally { refreshGate.resolve({}); vi.useRealTimers(); @@ -9808,6 +9816,28 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('reaps a sole failed restore even with a positive idle timeout', async () => { + const handle = makeChannel({ + loadSessionImpl: () => { + throw new Error('restore failed on an empty channel'); + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + channelIdleTimeoutMs: 60_000, + }); + + await expect( + bridge.loadSession({ + sessionId: 'failed-restore', + workspaceCwd: WS_A, + }), + ).rejects.toThrow(); + expect(handle.killed).toBe(true); + + await bridge.shutdown(); + }); + it('keeps a shared channel usable when restore fails beside a live session', async () => { const handles: ChannelHandle[] = []; const factory: ChannelFactory = async () => { @@ -9887,7 +9917,8 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('maps an ACP missing persisted session to SessionNotFoundError', async () => { + it('maps a missing persisted session and arms idle channel reaping', async () => { + vi.useFakeTimers(); const handles: ChannelHandle[] = []; const factory: ChannelFactory = async () => { const h = makeChannel({ @@ -9898,21 +9929,31 @@ describe('createAcpSessionBridge', () => { handles.push(h); return h.channel; }; - const bridge = makeBridge({ channelFactory: factory }); + const bridge = makeBridge({ + channelFactory: factory, + channelIdleTimeoutMs: 5_000, + }); - await expect( - bridge.loadSession({ + try { + await expect( + bridge.loadSession({ + sessionId: 'missing-persisted', + workspaceCwd: WS_A, + }), + ).rejects.toMatchObject({ + name: 'SessionNotFoundError', sessionId: 'missing-persisted', - workspaceCwd: WS_A, - }), - ).rejects.toMatchObject({ - name: 'SessionNotFoundError', - sessionId: 'missing-persisted', - }); - expect(bridge.sessionCount).toBe(0); - expect(handles[0]?.killed).toBe(false); + }); + expect(bridge.sessionCount).toBe(0); + expect(handles[0]?.killed).toBe(false); - await bridge.shutdown(); + await vi.advanceTimersByTimeAsync(5_001); + + expect(handles[0]?.killed).toBe(true); + } finally { + await bridge.shutdown(); + vi.useRealTimers(); + } }); // The `isAcpSessionResourceNotFound` `message`-fallback path can't @@ -12125,14 +12166,17 @@ describe('createAcpSessionBridge', () => { // `channel.exited` fires (OS-level reap), not be eagerly cleared // by `shutdown()`. const killSyncInvoked: string[] = []; + const teardown = deferred(); const factory: ChannelFactory = async () => { const h = makeChannel({ sessionIdPrefix: 's' }); + const realKill = h.channel.kill; const realKillSync = h.channel.killSync; h.channel = { ...h.channel, - kill: () => - // Never resolve — simulates a stuck SIGTERM grace window. - new Promise(() => {}), + kill: async () => { + await teardown.promise; + await realKill(); + }, killSync: () => { killSyncInvoked.push('called'); realKillSync(); @@ -12143,8 +12187,7 @@ describe('createAcpSessionBridge', () => { const bridge = makeBridge({ channelFactory: factory }); await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - // Kick off shutdown — its `channel.kill()` will hang on the - // never-resolving Promise above, so the entry maps clear but + // Kick off shutdown — its `channel.kill()` is held below, so the entry maps clear but // the channel-kill await never finishes. This is the mid-drain // state. const shutdownPromise = bridge.shutdown(); @@ -12161,10 +12204,8 @@ describe('createAcpSessionBridge', () => { // sync prefix. expect(killSyncInvoked).toHaveLength(1); - // Cleanup: the never-resolving kill keeps shutdownPromise - // pending forever. Don't await it (would hang the test). The - // test runner GCs it when this `it` returns. - void shutdownPromise; + teardown.resolve(); + await shutdownPromise; }); it('killAllSync force-kills the channel during the initialize handshake (tanzhenxin cold-spawn-window)', async () => { @@ -28251,6 +28292,63 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('tracks a stuck final cancel and retires its channel at the deadline', async () => { + const stalledWriteStarted = deferred(); + const stalledWrite = deferred(); + const handle = makeChannel(); + const originalWritable = handle.channel.stream.writable; + let writesBeforeStall = Number.POSITIVE_INFINITY; + handle.channel.stream = { + ...handle.channel.stream, + writable: new WritableStream({ + async write(message) { + if (writesBeforeStall === 0) { + stalledWriteStarted.resolve(); + await stalledWrite.promise; + return; + } + writesBeforeStall--; + const writer = originalWritable.getWriter(); + try { + await writer.write(message); + } finally { + writer.releaseLock(); + } + }, + }), + }; + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + initializeTimeoutMs: 20, + channelIdleTimeoutMs: 60_000, + }); + + try { + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + writesBeforeStall = 1; + const close = bridge.closeSession(session.sessionId); + await stalledWriteStarted.promise; + + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'active', + runtimeLive: true, + activeWork: true, + }); + await expect(close).resolves.toBeUndefined(); + expect(handle.killed).toBe(true); + await vi.waitFor(() => + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'cold', + runtimeLive: false, + activeWork: false, + }), + ); + } finally { + stalledWrite.resolve(); + await bridge.shutdown(); + } + }); + it('resolves pending permissions before waiting for agent close during kill', async () => { let capturedConn: AgentSideConnection | undefined; const permissionResponse: { current?: Promise } = {}; @@ -31299,37 +31397,1018 @@ describe('channelIdleTimeoutMs', () => { }); describe('preheat', () => { - it('spawns channel that is reused by first session', async () => { - let factoryCalls = 0; - const factory: ChannelFactory = async () => { - factoryCalls++; - return makeChannel().channel; - }; + it('reports cold, starting, and idle from one atomic lifecycle snapshot', async () => { + const handle = makeChannel(); + const factoryResult = deferred(); + const factory: ChannelFactory = async () => await factoryResult.promise; const bridge = makeBridge({ channelFactory: factory }); - await bridge.preheat(); - expect(factoryCalls).toBe(1); - const session = await bridge.spawnOrAttach({ - workspaceCwd: WS_A, - sessionScope: 'thread', + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toEqual({ + state: 'cold', + runtimeLive: false, + runtimeEpoch: 0, + activeWork: false, + }); + + const preheat = bridge.preheat(); + await vi.waitFor(() => { + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'starting', + runtimeLive: false, + runtimeEpoch: 0, + activeWork: true, + }); + }); + + factoryResult.resolve(handle.channel); + await preheat; + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toEqual({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + activeWork: false, }); - expect(session.sessionId).toBeDefined(); - expect(factoryCalls).toBe(1); - expect(bridge.sessionCount).toBe(1); - await bridge.closeSession(session.sessionId); await bridge.shutdown(); }); - it('is a no-op after shutdown', async () => { - let factoryCalls = 0; - const factory: ChannelFactory = async () => { - factoryCalls++; - return makeChannel().channel; + it('keeps an outer runtime reservation after channel startup completes', async () => { + const handle = makeChannel(); + const preheatInnerCompleted = deferred(); + const releasePreheat = deferred(); + const telemetry: BridgeTelemetry = { + captureContext: () => undefined, + runWithContext: (_captured, fn) => fn(), + async withSpan(operation, _attributes, fn) { + const result = await fn(); + if (operation === 'channel.preheat') { + preheatInnerCompleted.resolve(); + await releasePreheat.promise; + } + return result; + }, + event() {}, + injectPromptContext: (request) => request, }; - const bridge = makeBridge({ channelFactory: factory }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + telemetry, + }); + + const preheat = bridge.preheat(); + await preheatInnerCompleted.promise; + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'active', + runtimeLive: true, + activeWork: true, + }); + + releasePreheat.resolve(); + await preheat; + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'idle', + runtimeLive: true, + activeWork: false, + }); + + await bridge.shutdown(); + }); + + it('rejects a channel that exits before initialization can publish it', async () => { + const handle = makeChannel({ + initializeImpl: async () => { + handle.crash({ exitCode: 1, signalCode: null }); + await Promise.resolve(); + return { + protocolVersion: PROTOCOL_VERSION, + agentInfo: { name: 'fake-agent', version: '0' }, + authMethods: [], + agentCapabilities: {}, + }; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + + await expect(bridge.preheat()).rejects.toBeInstanceOf( + BridgeChannelClosedError, + ); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toEqual({ + state: 'cold', + runtimeLive: false, + runtimeEpoch: 0, + activeWork: false, + }); + await bridge.shutdown(); + }); + + it('clears a timed-out physical spawn so a later preheat can retry', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const stuckFactory = new Promise(() => {}); + let factoryCalls = 0; + const factory: ChannelFactory = async () => { + factoryCalls++; + if (factoryCalls === 1) return await stuckFactory; + return handle.channel; + }; + const bridge = makeBridge({ + channelFactory: factory, + initializeTimeoutMs: 50, + }); + + const firstPreheat = bridge.preheat(); + void firstPreheat.catch(() => undefined); + await vi.waitFor(() => expect(factoryCalls).toBe(1)); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!().state).toBe( + 'starting', + ); + + await vi.advanceTimersByTimeAsync(50); + await expect(firstPreheat).rejects.toThrow(/channel factory timed out/); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toEqual({ + state: 'cold', + runtimeLive: false, + runtimeEpoch: 0, + activeWork: false, + }); + + await bridge.preheat(); + expect(factoryCalls).toBe(2); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + }); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('kills a channel returned after its factory deadline without publishing it', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const factoryResult = deferred(); + const bridge = makeBridge({ + channelFactory: async () => await factoryResult.promise, + initializeTimeoutMs: 50, + }); + + const preheat = bridge.preheat(); + void preheat.catch(() => undefined); + await vi.waitFor(() => { + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!().state).toBe( + 'starting', + ); + }); + await vi.advanceTimersByTimeAsync(50); + await expect(preheat).rejects.toThrow(/channel factory timed out/); + + factoryResult.resolve(handle.channel); + await vi.waitFor(() => expect(handle.killed).toBe(true)); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toEqual({ + state: 'cold', + runtimeLive: false, + runtimeEpoch: 0, + activeWork: false, + }); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps runtime epochs monotonic across bridge replacement', async () => { + let runtimeEpoch = 0; + const runtimeEpochSource: BridgeRuntimeEpochSource = { + current: () => runtimeEpoch, + allocate: () => ++runtimeEpoch, + }; + const firstHandle = makeChannel(); + const firstBridge = makeBridge({ + channelFactory: async () => firstHandle.channel, + runtimeEpochSource, + }); + + await firstBridge.preheat(); + expect( + firstBridge.getWorkspaceRuntimeLifecycleSnapshot!().runtimeEpoch, + ).toBe(1); + await firstBridge.shutdown(); + + const secondHandle = makeChannel(); + const secondBridge = makeBridge({ + channelFactory: async () => secondHandle.channel, + runtimeEpochSource, + }); + expect(secondBridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'cold', + runtimeLive: false, + runtimeEpoch: 1, + }); + + await secondBridge.preheat(); + expect( + secondBridge.getWorkspaceRuntimeLifecycleSnapshot!().runtimeEpoch, + ).toBe(2); + await secondBridge.shutdown(); + }); + + it('keeps an idle-timeout-zero channel alive while workspace status is pending', async () => { + const statusResult = deferred>(); + const statusMethod = 'qwen/status/workspace/test'; + const handle = makeChannel({ + extMethodImpl: async (method) => { + if (method === statusMethod) return await statusResult.promise; + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + channelIdleTimeoutMs: 0, + }); await bridge.preheat(); + + const status = bridge.queryWorkspaceStatus(statusMethod, () => ({ + idle: true, + })); + await vi.waitFor(() => + expect(handle.agent.extMethodCalls).toContainEqual({ + method: statusMethod, + params: { cwd: WS_A }, + }), + ); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'active', + runtimeLive: true, + activeWork: true, + }); + expect(handle.killed).toBe(false); + + statusResult.resolve({ ready: true }); + await expect(status).resolves.toEqual({ ready: true }); + expect(handle.killed).toBe(true); + await vi.waitFor(() => { + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'cold', + runtimeLive: false, + activeWork: false, + }); + }); + + await bridge.shutdown(); + }); + + it('reaps an idle-timeout-zero channel whose session is removed mid-initialization', async () => { + const handle = makeChannel({ + extMethodImpl: (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.sessionApprovalMode) { + throw new Error('approval mode rejected'); + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + channelIdleTimeoutMs: 0, + }); + + const err = await bridge + .spawnOrAttach({ + workspaceCwd: WS_A, + approvalMode: ApprovalMode.YOLO, + }) + .then( + () => null, + (e: unknown) => e, + ); + + expect(err).not.toBeNull(); + await vi.waitFor(() => expect(handle.killed).toBe(true)); + await vi.waitFor(() => + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'cold', + runtimeLive: false, + activeWork: false, + }), + ); + + await bridge.shutdown(); + }); + + it('aborts the startup signal when channel initialization fails', async () => { + let capturedSignal: AbortSignal | undefined; + const handle = makeChannel({ + initializeThrows: new Error('handshake refused'), + }); + const bridge = makeBridge({ + channelFactory: async (_workspaceCwd, _childEnvOverrides, signal) => { + capturedSignal = signal; + return handle.channel; + }, + }); + + const err = await bridge.preheat().then( + () => null, + (e: unknown) => e, + ); + + expect(err).not.toBeNull(); + expect(capturedSignal?.aborted).toBe(true); + expect(handle.killed).toBe(true); + await bridge.shutdown(); + }); + + it('aborts the startup signal when the runtime epoch regresses', async () => { + let capturedSignal: AbortSignal | undefined; + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async (_workspaceCwd, _childEnvOverrides, signal) => { + capturedSignal = signal; + return handle.channel; + }, + runtimeEpochSource: { + current: () => 5, + allocate: () => 5, + }, + }); + + await expect(bridge.preheat()).rejects.toThrow(/monotonically/); + expect(capturedSignal?.aborted).toBe(true); + expect(handle.killed).toBe(true); + await bridge.shutdown(); + }); + + it('aborts the startup signal when the channel factory times out', async () => { + vi.useFakeTimers(); + try { + let capturedSignal: AbortSignal | undefined; + const handle = makeChannel(); + const factoryResult = deferred(); + const bridge = makeBridge({ + channelFactory: async (_workspaceCwd, _childEnvOverrides, signal) => { + capturedSignal = signal; + return await factoryResult.promise; + }, + initializeTimeoutMs: 50, + }); + + const preheat = bridge.preheat(); + void preheat.catch(() => undefined); + await vi.advanceTimersByTimeAsync(50); + await expect(preheat).rejects.toThrow(/channel factory timed out/); + expect(capturedSignal?.aborted).toBe(true); + + factoryResult.resolve(handle.channel); + await vi.waitFor(() => expect(handle.killed).toBe(true)); + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + { + label: 'MCP initialization', + method: SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize, + timeoutMs: 50, + invoke: (bridge: ReturnType) => + bridge.initializeWorkspaceMcp(), + }, + { + label: 'MCP authentication', + method: SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, + timeoutMs: 600_000, + invoke: (bridge: ReturnType) => + bridge.manageMcpServer('aone', 'authenticate', undefined), + }, + ])( + 'retires a kept-alive channel when $label exceeds its physical lease', + async ({ method, timeoutMs, invoke }) => { + vi.useFakeTimers(); + try { + const stalledResult = deferred>(); + const firstHandle = makeChannel({ + extMethodImpl: async (candidate) => + candidate === method ? await stalledResult.promise : {}, + }); + const secondHandle = makeChannel(); + const channelFactory = vi + .fn() + .mockResolvedValueOnce(firstHandle.channel) + .mockResolvedValueOnce(secondHandle.channel); + const bridge = makeBridge({ + channelFactory, + initializeTimeoutMs: 50, + }); + + await bridge.preheat({ keepAliveMs: 600_000 }); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + }); + + const request = invoke(bridge); + void request.catch(() => undefined); + await vi.waitFor(() => + expect(firstHandle.agent.extMethodCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method, + }), + ]), + ), + ); + + await vi.advanceTimersByTimeAsync(timeoutMs); + await expect(request).rejects.toBeInstanceOf(BridgeTimeoutError); + expect(firstHandle.killed).toBe(true); + await vi.waitFor(() => + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'cold', + runtimeLive: false, + runtimeEpoch: 1, + activeWork: false, + }), + ); + + await bridge.preheat({ keepAliveMs: 600_000 }); + expect(channelFactory).toHaveBeenCalledTimes(2); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 2, + }); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }, + ); + + it('retires a kept-alive channel when MCP detail discovery times out', async () => { + vi.useFakeTimers(); + const detailResult = deferred>(); + const firstHandle = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { + return { + v: 1, + workspaceCwd: WS_A, + initialized: true, + discoveryState: 'completed', + servers: [{ name: 'aone', mcpStatus: 'connected' }], + }; + } + if ( + method === SERVE_STATUS_EXT_METHODS.workspaceMcpTools || + method === SERVE_STATUS_EXT_METHODS.workspaceMcpResources + ) { + return await detailResult.promise; + } + return {}; + }, + }); + const secondHandle = makeChannel(); + const channelFactory = vi + .fn() + .mockResolvedValueOnce(firstHandle.channel) + .mockResolvedValueOnce(secondHandle.channel); + const bridge = makeBridge({ + channelFactory, + initializeTimeoutMs: 50, + }); + + try { + await bridge.preheat({ keepAliveMs: 600_000 }); + const status = bridge.queryWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + () => ({ discoveryState: 'not_started', servers: [] }), + ); + void status.catch(() => undefined); + await vi.advanceTimersByTimeAsync(0); + expect(firstHandle.agent.extMethodCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: SERVE_STATUS_EXT_METHODS.workspaceMcpTools, + }), + expect.objectContaining({ + method: SERVE_STATUS_EXT_METHODS.workspaceMcpResources, + }), + ]), + ); + + await vi.advanceTimersByTimeAsync(50); + await expect(status).resolves.toMatchObject({ + discoveryState: 'completed', + }); + expect(firstHandle.killed).toBe(true); + await vi.waitFor(() => + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'cold', + runtimeLive: false, + runtimeEpoch: 1, + activeWork: false, + }), + ); + + await bridge.preheat({ keepAliveMs: 600_000 }); + expect(channelFactory).toHaveBeenCalledTimes(2); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 2, + }); + } finally { + detailResult.resolve({}); + await bridge.shutdown(); + vi.useRealTimers(); + } + }); + + it('keeps a shared channel while a timed-out new session RPC settles', async () => { + vi.useFakeTimers(); + const stalledStarted = deferred(); + const stalled = deferred>(); + let newSessionCalls = 0; + const firstHandle = makeChannel({ + extMethodImpl: activeWorkCloseImpl, + newSessionImpl: async () => { + newSessionCalls++; + if (newSessionCalls === 1) { + return { sessionId: 'live-session' }; + } + stalledStarted.resolve(); + return (await stalled.promise) as NewSessionResponse; + }, + }); + const secondHandle = makeChannel(); + const channelFactory = vi + .fn() + .mockResolvedValueOnce(firstHandle.channel) + .mockResolvedValueOnce(secondHandle.channel); + const bridge = makeBridge({ + channelFactory, + initializeTimeoutMs: 50, + sessionScope: 'thread', + }); + + try { + await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + const request = bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + void request.catch(() => undefined); + await stalledStarted.promise; + + await vi.advanceTimersByTimeAsync(50); + await expect(request).rejects.toBeInstanceOf(BridgeTimeoutError); + expect(firstHandle.killed).toBe(false); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'active', + runtimeLive: true, + runtimeEpoch: 1, + activeWork: true, + }); + + stalled.resolve({ sessionId: 'late-session' }); + await vi.advanceTimersByTimeAsync(0); + await bridge.preheat({ keepAliveMs: 600_000 }); + expect(channelFactory).toHaveBeenCalledTimes(1); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'active', + runtimeLive: true, + runtimeEpoch: 1, + }); + } finally { + stalled.resolve({ sessionId: 'late-session' }); + await bridge.shutdown(); + vi.useRealTimers(); + } + }); + + it('transfers an outer reservation before an idle workspace call drains', async () => { + const statusResult = deferred>(); + const initializeResult = deferred>(); + const statusMethod = 'qwen/status/workspace/reservation-race'; + const handle = makeChannel({ + extMethodImpl: async (method) => { + if (method === statusMethod) return await statusResult.promise; + if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize) { + return await initializeResult.promise; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + channelIdleTimeoutMs: 0, + }); + await bridge.preheat(); + + const status = bridge.queryWorkspaceStatus(statusMethod, () => ({ + idle: true, + })); + await vi.waitFor(() => + expect(handle.agent.extMethodCalls).toContainEqual({ + method: statusMethod, + params: { cwd: WS_A }, + }), + ); + + statusResult.resolve({ ready: true }); + const initialize = bridge.initializeWorkspaceMcp(); + await vi.waitFor(() => + expect(handle.agent.extMethodCalls).toContainEqual({ + method: SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize, + params: { cwd: WS_A }, + }), + ); + expect(handle.killed).toBe(false); + + initializeResult.resolve({ accepted: false }); + await expect(status).resolves.toEqual({ ready: true }); + await expect(initialize).resolves.toEqual({ accepted: false }); + expect(handle.killed).toBe(true); + + await bridge.shutdown(); + }); + + it('terminates the owning channel when MCP discovery exceeds its lease', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel({ + extMethodImpl: async (method) => + method === SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize + ? { accepted: true } + : {}, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + + await expect(bridge.initializeWorkspaceMcp()).resolves.toEqual({ + accepted: true, + }); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'active', + runtimeLive: true, + activeWork: true, + }); + + await vi.advanceTimersByTimeAsync(300_000); + expect(handle.killed).toBe(true); + await vi.waitFor(() => + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'cold', + runtimeLive: false, + activeWork: false, + }), + ); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('drains missing MCP auth through its owning channel with an active session', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpManage) { + return { + serverName: 'aone', + action: 'authenticate', + ok: true, + pending: true, + }; + } + if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { + return { + v: 1, + workspaceCwd: WS_A, + initialized: true, + discoveryState: 'completed', + servers: [], + }; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await expect( + bridge.manageMcpServer('aone', 'authenticate', undefined), + ).resolves.toMatchObject({ + serverName: 'aone', + action: 'authenticate', + pending: true, + }); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'active', + runtimeLive: true, + activeWork: true, + }); + + await vi.advanceTimersByTimeAsync(600_000); + expect(handle.killed).toBe(true); + await vi.waitFor(() => + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'cold', + runtimeLive: false, + activeWork: false, + }), + ); + expect(bridge.sessionCount).toBe(0); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('rechecks MCP auth before its deadline can terminate active sessions', async () => { + vi.useFakeTimers(); + try { + let statusRequests = 0; + const handle = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpManage) { + return { + serverName: 'aone', + action: 'authenticate', + ok: true, + pending: true, + }; + } + if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { + statusRequests += 1; + return { + v: 1, + workspaceCwd: WS_A, + initialized: true, + discoveryState: 'completed', + servers: [ + { + name: 'aone', + authenticationState: + statusRequests === 1 ? 'pending' : 'succeeded', + }, + ], + }; + } + return {}; + }, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.manageMcpServer('aone', 'authenticate', undefined); + expect(statusRequests).toBe(1); + + await vi.advanceTimersByTimeAsync(600_000); + await vi.waitFor(() => expect(statusRequests).toBe(2)); + expect(handle.killed).toBe(false); + expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ + state: 'active', + runtimeLive: true, + activeWork: true, + }); + + await bridge.closeSession(session.sessionId); + expect(handle.killed).toBe(true); + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not let a superseded MCP auth timer terminate the renewed channel', async () => { + vi.useFakeTimers(); + try { + let authenticationRequests = 0; + let statusRequests = 0; + const expiryStatusStarted = deferred(); + const expiryStatus = deferred>(); + const handle = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpManage) { + authenticationRequests++; + return { + serverName: 'aone', + action: 'authenticate', + ok: true, + pending: true, + }; + } + if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { + statusRequests++; + if (statusRequests === 2) { + expiryStatusStarted.resolve(); + return await expiryStatus.promise; + } + return { + v: 1, + workspaceCwd: WS_A, + initialized: true, + discoveryState: 'completed', + servers: [{ name: 'aone', authenticationState: 'pending' }], + }; + } + return {}; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.manageMcpServer('aone', 'authenticate', undefined); + + const expiry = vi.advanceTimersByTimeAsync(600_000); + await expiryStatusStarted.promise; + const renewal = bridge.manageMcpServer('aone', 'authenticate', undefined); + await vi.waitFor(() => expect(authenticationRequests).toBe(2)); + expiryStatus.resolve({ + v: 1, + workspaceCwd: WS_A, + initialized: true, + discoveryState: 'completed', + servers: [{ name: 'aone', authenticationState: 'pending' }], + }); + await Promise.all([expiry, renewal]); + + expect(handle.killed).toBe(false); + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('honors the longest keep-alive from concurrent preheats', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const factoryResult = deferred(); + let factoryCalls = 0; + const bridge = makeBridge({ + channelFactory: async () => { + factoryCalls++; + return await factoryResult.promise; + }, + }); + + const shortPreheat = bridge.preheat({ keepAliveMs: 1_000 }); + const longPreheat = bridge.preheat({ keepAliveMs: 10_000 }); + await vi.waitFor(() => expect(factoryCalls).toBe(1)); + factoryResult.resolve(handle.channel); + await Promise.all([shortPreheat, longPreheat]); + + await vi.advanceTimersByTimeAsync(9_999); + expect(handle.killed).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(handle.killed).toBe(true); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('arms a keep-alive when a concurrent plain preheat finishes last', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const releasePlainPreheat = deferred(); + let preheatSpanCount = 0; + const telemetry: BridgeTelemetry = { + captureContext: () => undefined, + runWithContext: (_captured, fn) => fn(), + async withSpan(operation, _attributes, fn) { + const preheatIndex = + operation === 'channel.preheat' ? ++preheatSpanCount : 0; + const result = await fn(); + if (preheatIndex === 2) await releasePlainPreheat.promise; + return result; + }, + event() {}, + injectPromptContext: (request) => request, + }; + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + telemetry, + }); + + const keptAlive = bridge.preheat({ keepAliveMs: 600_000 }); + const plain = bridge.preheat(); + await keptAlive; + expect(handle.killed).toBe(false); + + releasePlainPreheat.resolve(); + await plain; + await vi.advanceTimersByTimeAsync(599_999); + expect(handle.killed).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(handle.killed).toBe(true); + + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('renews an explicitly ensured runtime warm window', async () => { + vi.useFakeTimers(); + try { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + }); + + await bridge.preheat({ keepAliveMs: 600_000 }); + expect(bridge.getDaemonStatusSnapshot().limits.channelIdleTimeoutMs).toBe( + 0, + ); + await vi.advanceTimersByTimeAsync(300_000); + await bridge.preheat({ keepAliveMs: 600_000 }); + await vi.advanceTimersByTimeAsync(599_999); + expect(handle.killed).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(handle.killed).toBe(true); + await bridge.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it.each([ + ['an omitted timeout', undefined], + ['an explicit zero timeout', 0], + ] as const)('preserves the preheated channel with %s', async (_, timeout) => { + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + ...(timeout === undefined ? {} : { channelIdleTimeoutMs: timeout }), + }); + + await bridge.preheat(); + + expect(handle.killed).toBe(false); + expect(bridge.isChannelLive()).toBe(true); + await bridge.shutdown(); + }); + + it('spawns channel that is reused by first session', async () => { + let factoryCalls = 0; + const factory: ChannelFactory = async () => { + factoryCalls++; + return makeChannel().channel; + }; + const bridge = makeBridge({ channelFactory: factory }); + await bridge.preheat(); + expect(factoryCalls).toBe(1); + + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + expect(session.sessionId).toBeDefined(); + expect(factoryCalls).toBe(1); + expect(bridge.sessionCount).toBe(1); + + await bridge.closeSession(session.sessionId); + await bridge.shutdown(); + }); + + it('rejects after shutdown without spawning a channel', async () => { + let factoryCalls = 0; + const factory: ChannelFactory = async () => { + factoryCalls++; + return makeChannel().channel; + }; + const bridge = makeBridge({ channelFactory: factory }); + await bridge.shutdown(); + await expect(bridge.preheat()).rejects.toThrow( + 'AcpSessionBridge is shutting down', + ); expect(factoryCalls).toBe(0); }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 05f2761ca5b..e9f027ecab7 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -891,8 +891,10 @@ interface ChannelInfo { * Live session ids multiplexed on this channel. Updated when * `doSpawn` registers a new session and when `killSession` / * `channel.exited` removes one. When the set drops to empty under - * `killSession`, the channel is marked `isDying = true` and its - * `channel.kill()` is awaited; `channelInfo` itself is left + * `killSession`, the workspace idle policy is scheduled via + * `startIdleTimer`; `killChannelWithLog` / `reapPendingEmptyChannel` + * are the actual `isDying = true` set-sites on that path, and + * `channel.kill()` is awaited there. `channelInfo` itself is left * pointing at the dying channel until `channel.exited` fires (see * BkUyD invariant on `isDying` below). */ @@ -992,8 +994,11 @@ interface ChannelInfo { * during handshake). * 3. `doSpawn`: newSession-failure on an empty channel * (sessionIds.size === 0). - * 4. `killSession`: last session leaving (sessionIds.size === 0 - * after the delete). + * 4. `killSession` last-session-leaving (sessionIds.size === 0 + * after the delete) — indirectly: it schedules the idle policy + * via `startIdleTimer`, and `killChannelWithLog` (immediate at + * a resolved timeout <= 0, or on timer expiry) / + * `reapPendingEmptyChannel` perform the actual set. * 5. `shutdown`: bulk-mark every entry in `aliveChannels`. * 6. `ensureChannel`: a channel-level transport-failure signal. * @@ -2799,6 +2804,17 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const newSessionSettlementGraceMs = initTimeoutMs; const abandonedNewSessionRetryAfterSeconds = restoreRetryAfterSeconds(initTimeoutMs); + let localRuntimeEpoch = 0; + const runtimeEpochSource = opts.runtimeEpochSource ?? { + current: () => localRuntimeEpoch, + allocate: () => ++localRuntimeEpoch, + }; + const initialRuntimeEpoch = runtimeEpochSource.current(); + if (!Number.isSafeInteger(initialRuntimeEpoch) || initialRuntimeEpoch < 0) { + throw new TypeError( + `Invalid initial runtime epoch: ${initialRuntimeEpoch}.`, + ); + } const sessionRestoreTimeoutMs = resolveSessionRestoreTimeoutMs(opts); // Retry hint for an id fenced behind an abandoned restore. The underlying // ACP request already exceeded the full budget, so the next useful retry is @@ -2880,9 +2896,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // `channelInfo` is the SINGLE attach-available channel. Cleared // ONLY by the `channel.exited` handler (see below) when the OS // reaps the underlying child process. Teardown initiators - // (`killSession` last-session-leaving, `doSpawn`-newSession-failure - // on an empty channel, `ensureChannel` init-failure / - // late-shutdown, `shutdown`) set `isDying = true` but LEAVE + // (`killSession` last-session-leaving — via `startIdleTimer` -> + // `killChannelWithLog` / `reapPendingEmptyChannel`, + // `doSpawn`-newSession-failure on an empty channel, `ensureChannel` + // init-failure / late-shutdown, `shutdown`) set `isDying = true` + // but LEAVE // `channelInfo` pointing at the dying channel until OS reap — that // asymmetry IS the BkUyD invariant. It lets `killAllSync` reach a // mid-SIGTERM-grace channel through `aliveChannels` while a @@ -2892,6 +2910,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // `killAllSync`) gate on `isDying` rather than presence; see // `ChannelInfo.isDying` for the per-set-site rationale. let channelInfo: ChannelInfo | undefined; + let runtimeEpoch = initialRuntimeEpoch; + let keepAliveUntil = 0; + let runtimeOperationReservations = 0; + const pendingKeepAliveDeadlines = new Map(); let workspaceMcpStatusCache: ServeWorkspaceMcpStatus | undefined; const workspaceMcpToolsCache = new Map< string, @@ -3350,6 +3372,25 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } } + async function terminateChannel( + channel: AcpChannel, + context: string, + ): Promise { + try { + await withTimeout(channel.kill(), initTimeoutMs, `${context} teardown`); + } catch (error) { + try { + channel.killSync(); + } catch (forceError) { + throw new AggregateError( + [error, forceError], + `ACP channel teardown failed (${context})`, + ); + } + throw error; + } + } + function channelUnavailableReject( channel: AcpChannel, context: string, @@ -3369,24 +3410,47 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ): Promise { ci.isDying = true; ci.channelLiveness?.stop(); - await ci.channel.kill().catch((err) => { - writeStderrLine( - `qwen serve: channel kill failed${context ? ` (${context})` : ''}: ${String(err)}`, - ); - }); + await terminateChannel(ci.channel, context ?? 'channel kill').catch( + (err) => { + writeStderrLine( + `qwen serve: channel kill failed${context ? ` (${context})` : ''}: ${String(err)}`, + ); + }, + ); } - function resolvedChannelIdleTimeoutMs(): number { + async function retireChannelOnTimeout( + ci: ChannelInfo, + error: unknown, + context: string, + ): Promise { + if (error instanceof BridgeTimeoutError && !ci.isDying) { + await killChannelWithLog(ci, context); + } + } + + function configuredChannelIdleTimeoutMs(): number { const raw = opts.channelIdleTimeoutMs; return raw !== undefined && Number.isFinite(raw) && raw > 0 ? Math.min(raw, 2_147_483_647) : 0; } + function resolvedChannelIdleTimeoutMs(): number { + const configured = configuredChannelIdleTimeoutMs(); + const now = Date.now(); + let pendingKeepAliveMs = 0; + for (const deadline of pendingKeepAliveDeadlines.values()) { + pendingKeepAliveMs = Math.max(pendingKeepAliveMs, deadline - now); + } + return Math.max(configured, keepAliveUntil - now, pendingKeepAliveMs); + } + async function startIdleTimer( ci: ChannelInfo, context?: string, ): Promise { + if (ci.isDying || liveChannelInfo() !== ci) return; const timeoutMs = resolvedChannelIdleTimeoutMs(); if (timeoutMs <= 0) { await killChannelWithLog(ci, context); @@ -3415,19 +3479,25 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const inFlightSpawnCount = ci.sessionSpawnsInFlight - (opts?.ignoreCurrentSessionSpawn === true ? 1 : 0); - const pendingRestoreCount = - ci.pendingRestoreIds.size - - (opts?.ignoreRestoreId !== undefined && - ci.pendingRestoreIds.has(opts.ignoreRestoreId) - ? 1 - : 0); + const ignoredRestoreIds = new Set(ci.unsettledAbandonedRestores); + if (opts?.ignoreRestoreId !== undefined) { + ignoredRestoreIds.add(opts.ignoreRestoreId); + } + const pendingRestoreCount = [...ci.pendingRestoreIds].filter( + (sessionId) => !ignoredRestoreIds.has(sessionId), + ).length; + const outerRestoreCount = [...inFlightRestores.keys()].filter( + (sessionId) => !ignoredRestoreIds.has(sessionId), + ).length; return ( ci.sessionIds.size === 0 && pendingRestoreCount === 0 && ci.workspaceControlInFlight === 0 && !ci.workspaceMcpDiscoveryInFlight && ci.workspaceMcpAuthenticationServerNames.size === 0 && - inFlightSpawnCount === 0 + inFlightSpawnCount === 0 && + runtimeOperationReservations === 0 && + outerRestoreCount <= 0 ); } @@ -3441,11 +3511,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } ci.workspaceMcpDiscoveryTimer = setTimeout(() => { ci.workspaceMcpDiscoveryTimer = undefined; - ci.workspaceMcpDiscoveryInFlight = false; - ci.workspaceMcpDiscoveryRequested = false; - if (hasNoChannelWork(ci)) { - void startIdleTimer(ci, 'workspace MCP discovery timeout'); - } + if (ci.isDying) return; + void killChannelWithLog(ci, 'workspace MCP discovery timeout'); }, MCP_RESTART_TIMEOUT_MS); ci.workspaceMcpDiscoveryTimer.unref(); } @@ -3521,7 +3588,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { 'qwen-code.daemon.acp_channel.id': ci.id, 'session.id': sessionId, }); - void reapPendingEmptyChannel(ci); + void reapPendingEmptyChannel(ci, { ignoreRestoreId: sessionId }); }, restoreSettlementGraceMs); timer.unref(); ci.restoreSettlementTimers.set(sessionId, timer); @@ -3555,12 +3622,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ci.newSessionSettlementTimers.set(token, timer); } - async function reapPendingEmptyChannel(ci: ChannelInfo): Promise { - if (!channelShouldReapWhenIdle(ci) || !hasNoChannelWork(ci)) return; + async function reapPendingEmptyChannel( + ci: ChannelInfo, + opts?: { ignoreRestoreId?: string }, + ): Promise { + if (!channelShouldReapWhenIdle(ci) || !hasNoChannelWork(ci, opts)) return; ci.emptyReapPending = false; ci.isDying = true; ci.channelLiveness?.stop(); - await ci.channel.kill().catch(() => { + await terminateChannel(ci.channel, 'pending empty channel').catch(() => { /* best-effort — channel.exited handler still runs */ }); } @@ -3569,15 +3639,34 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ci: ChannelInfo, fn: () => Promise, ): Promise { + if (liveChannelInfo() === ci) cancelIdleTimer(); ci.workspaceControlInFlight++; try { return await fn(); + } catch (error) { + await retireChannelOnTimeout(ci, error, 'workspace control timeout'); + throw error; } finally { ci.workspaceControlInFlight = Math.max( 0, ci.workspaceControlInFlight - 1, ); await reapPendingEmptyChannel(ci); + if (!ci.isDying && liveChannelInfo() === ci && hasNoChannelWork(ci)) { + await startIdleTimer(ci, 'workspace control'); + } + } + } + + async function withEnsuredWorkspaceControl( + fn: (ci: ChannelInfo) => Promise, + ): Promise { + runtimeOperationReservations++; + try { + const ci = await ensureChannel(); + return await withWorkspaceControl(ci, () => fn(ci)); + } finally { + await releaseRuntimeOperationReservation('workspace control'); } } @@ -3908,6 +3997,30 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // context; running either twice for the same id at the same time can // duplicate history frames or race two entries into `byId`. const inFlightRestores = new Map(); + + async function settleReleasedRuntimeWork( + context: string, + armIdleTimer = true, + ): Promise { + for (const ci of Array.from(aliveChannels)) { + await reapPendingEmptyChannel(ci); + } + if (!armIdleTimer) return; + const ci = liveChannelInfo(); + if (ci && hasNoChannelWork(ci)) { + await startIdleTimer(ci, context); + } + } + + async function releaseRuntimeOperationReservation( + context: string, + ): Promise { + runtimeOperationReservations = Math.max( + 0, + runtimeOperationReservations - 1, + ); + await settleReleasedRuntimeWork(context); + } // `session/load` emits history replay as session_update notifications before // the ACP request returns. Keep a temporary bus so those replay frames land in // the ring, then promote the same bus into the registered SessionEntry. @@ -4037,6 +4150,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { * multiplexed sessions. */ async function ensureChannel(): Promise { + if (shuttingDown) { + throw new Error('AcpSessionBridge is shutting down'); + } // Skip a channel that's marked dying — its underlying transport is // mid-SIGTERM-or-already-dead and `connection.newSession()` on it // would either hang or land the caller with a sessionId that @@ -4048,7 +4164,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const promise = (async () => { const privateParentCapability = randomBytes(32).toString('base64url'); const acpChannelId = randomUUID(); - const channel = await telemetry.withSpan( + const startupStartedAt = Date.now(); + const startupAbort = new AbortController(); + const factoryPromise = telemetry.withSpan( 'channel.spawn', { 'qwen-code.daemon.bridge.operation': 'channel.spawn', @@ -4056,11 +4174,37 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { 'qwen-code.daemon.acp_channel.id': acpChannelId, }, async () => - await channelFactory(boundWorkspace, { - ...childEnvOverrides, - [PRIVATE_ACP_CAPABILITY_ENV]: privateParentCapability, - }), + await channelFactory( + boundWorkspace, + { + ...childEnvOverrides, + [PRIVATE_ACP_CAPABILITY_ENV]: privateParentCapability, + }, + startupAbort.signal, + ), ); + let channel: AcpChannel; + try { + channel = await withTimeout( + factoryPromise, + initTimeoutMs, + 'channel factory', + ); + } catch (error) { + startupAbort.abort(error); + void factoryPromise.then( + (lateChannel) => + terminateChannel(lateChannel, 'late channel factory result').catch( + (teardownError) => { + writeStderrLine( + `qwen serve: late ACP channel teardown failed: ${String(teardownError)}`, + ); + }, + ), + () => undefined, + ); + throw error; + } const sessionIds = new Set(); const infoRef: { current?: ChannelInfo } = {}; let client: BridgeClient; @@ -4213,7 +4357,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // supplies the bounded failure path when exit is never observed. await Promise.race([ channel.exited.then(() => undefined), - channel.kill(), + terminateChannel(channel, 'channel construction failure'), ]); } catch (teardownError) { throw new AggregateError( @@ -4448,6 +4592,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { 'qwen-code.daemon.acp_channel.id': acpChannelId, }, async () => { + const remainingStartupMs = Math.max( + 1, + initTimeoutMs - (Date.now() - startupStartedAt), + ); const response = await withTimeout( Promise.race([ connection.initialize({ @@ -4477,7 +4625,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }), channelUnavailableReject(channel, 'during initialize'), ]), - initTimeoutMs, + remainingStartupMs, 'initialize', ); if (opts.externalToolGuard) { @@ -4546,7 +4694,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // newSession-failure / `shutdown`): "any channel in // `aliveChannels` with `isDying === true` is mid-teardown." info.isDying = true; - await channel.kill().catch(() => {}); + startupAbort.abort(err); + await terminateChannel(channel, 'channel initialization failure').catch( + () => undefined, + ); throw err; } @@ -4561,15 +4712,46 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // path: mark dying + kill, let the exited handler reap. if (shuttingDown) { info.isDying = true; - await channel.kill().catch(() => {}); + startupAbort.abort(new Error('AcpSessionBridge is shutting down')); + await terminateChannel(channel, 'late shutdown').catch(() => undefined); throw new Error('AcpSessionBridge is shutting down'); } + if (!aliveChannels.has(info)) { + info.isDying = true; + const error = new BridgeChannelClosedError( + 'during channel initialization', + ); + startupAbort.abort(error); + await terminateChannel(channel, 'exited during initialization').catch( + () => undefined, + ); + throw error; + } // Handshake succeeded — now publish the channel as the // attach-available slot. `channelInfo` is assigned LAST so // `ensureChannel`'s fast-path (`if (channelInfo && !.isDying)`) // never returns a still-handshaking channel to a concurrent // caller. + const previousRuntimeEpoch = runtimeEpochSource.current(); + const nextRuntimeEpoch = runtimeEpochSource.allocate(); + if ( + !Number.isSafeInteger(previousRuntimeEpoch) || + previousRuntimeEpoch < runtimeEpoch || + !Number.isSafeInteger(nextRuntimeEpoch) || + nextRuntimeEpoch <= previousRuntimeEpoch + ) { + info.isDying = true; + const epochError = new Error( + `Runtime epoch source must increase monotonically (local=${runtimeEpoch}, current=${previousRuntimeEpoch}, next=${nextRuntimeEpoch}).`, + ); + startupAbort.abort(epochError); + await terminateChannel(channel, 'invalid runtime epoch').catch( + () => undefined, + ); + throw epochError; + } + runtimeEpoch = nextRuntimeEpoch; channelInfo = info; info.handshakeComplete = true; if (channelLivenessNegotiated) { @@ -5615,11 +5797,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const assertLivePromptEntry = ( sessionId: string, entry: SessionEntry, - ): void => { + ): ChannelInfo => { const info = channelInfoForEntry(entry); if (byId.get(sessionId) !== entry || !info || info.isDying) { throw new SessionNotFoundError(sessionId); } + return info; }; const getChannelClosedReject = (info: ChannelInfo): Promise => { @@ -5662,7 +5845,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { method, ); cache.set(serverName, result as unknown as T); - } catch { + } catch (error) { + await retireChannelOnTimeout( + info, + error, + `workspace MCP detail timeout for ${serverName}`, + ); // The base MCP status remains useful when one detail query fails. } }; @@ -5734,94 +5922,127 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } return idle(); } - let response = await withTimeout( - Promise.race([ - info.connection.extMethod(method, { ...params, cwd: boundWorkspace }), - getChannelClosedReject(info), - ]), - initTimeoutMs, - method, - ); - if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { - const rawStatus = response as unknown as ServeWorkspaceMcpStatus; - if (!Array.isArray(rawStatus.servers)) { - return response as unknown as T; - } - const rawServers = rawStatus.servers; - const effectiveManagedServerNames = new Set([ - ...info.workspaceMcpAuthenticationServerNames, - ...(managedServerNames ?? []), - ]); - if ( - effectiveManagedServerNames.size > 0 || - (workspaceMcpStatusCache?.discoveryState === 'completed' && - rawStatus.discoveryState === 'not_started' && - rawServers.length === 0) - ) { - response = mergeManagedWorkspaceMcpStatus( - effectiveManagedServerNames, - workspaceMcpStatusCache, - rawStatus, - ) as unknown as typeof response; - } - const status = response as { - discoveryState?: unknown; - servers?: unknown; - errors?: unknown; - }; - if (status.discoveryState === 'completed') { - await cacheWorkspaceMcpDetails( - info, - effectiveManagedServerNames.size > 0 - ? { - servers: rawServers.filter((server) => - effectiveManagedServerNames.has(server.name), - ), - } - : status, - ); - } - if ( - info.workspaceMcpDiscoveryInFlight && - (status.discoveryState === 'completed' || - (Array.isArray(status.errors) && status.errors.length > 0)) - ) { - finishWorkspaceMcpDiscovery(info); - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'workspace MCP discovery complete'); - } - } - if (status.discoveryState === 'completed') { - info.workspaceMcpDiscoveryRequested = true; - } else if (status.discoveryState === 'in_progress') { - info.workspaceMcpDiscoveryRequested = true; - } else if (Array.isArray(status.errors) && status.errors.length > 0) { - info.workspaceMcpDiscoveryRequested = false; - } - let authenticationCompleted = false; - for (const serverName of info.workspaceMcpAuthenticationServerNames) { - const server = rawServers.find( - (candidate) => candidate.name === serverName, - ); + return await withWorkspaceControl(info, async () => { + let response = await withTimeout( + Promise.race([ + info.connection.extMethod(method, { + ...params, + cwd: boundWorkspace, + }), + getChannelClosedReject(info), + ]), + initTimeoutMs, + method, + ); + if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { + const rawStatus = response as unknown as ServeWorkspaceMcpStatus; + if (!Array.isArray(rawStatus.servers)) { + return response as unknown as T; + } + const rawServers = rawStatus.servers; + const effectiveManagedServerNames = new Set([ + ...info.workspaceMcpAuthenticationServerNames, + ...(managedServerNames ?? []), + ]); if ( - server?.authenticationState !== 'pending' && - (server !== undefined || rawStatus.discoveryState === 'completed') + effectiveManagedServerNames.size > 0 || + (workspaceMcpStatusCache?.discoveryState === 'completed' && + rawStatus.discoveryState === 'not_started' && + rawServers.length === 0) ) { - info.workspaceMcpAuthenticationServerNames.delete(serverName); - const timer = info.workspaceMcpAuthenticationTimers.get(serverName); - if (timer) clearTimeout(timer); - info.workspaceMcpAuthenticationTimers.delete(serverName); - authenticationCompleted = true; + response = mergeManagedWorkspaceMcpStatus( + effectiveManagedServerNames, + workspaceMcpStatusCache, + rawStatus, + ) as unknown as typeof response; + } + const status = response as { + discoveryState?: unknown; + servers?: unknown; + errors?: unknown; + }; + if (status.discoveryState === 'completed') { + await cacheWorkspaceMcpDetails( + info, + effectiveManagedServerNames.size > 0 + ? { + servers: rawServers.filter((server) => + effectiveManagedServerNames.has(server.name), + ), + } + : status, + ); } - } - if (authenticationCompleted) { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'workspace MCP authentication complete'); + if ( + info.workspaceMcpDiscoveryInFlight && + (status.discoveryState === 'completed' || + (Array.isArray(status.errors) && status.errors.length > 0)) + ) { + finishWorkspaceMcpDiscovery(info); + } + if (status.discoveryState === 'completed') { + info.workspaceMcpDiscoveryRequested = true; + } else if (status.discoveryState === 'in_progress') { + info.workspaceMcpDiscoveryRequested = true; + } else if (Array.isArray(status.errors) && status.errors.length > 0) { + info.workspaceMcpDiscoveryRequested = false; + } + for (const serverName of info.workspaceMcpAuthenticationServerNames) { + const server = rawServers.find( + (candidate) => candidate.name === serverName, + ); + if ( + server !== undefined && + server.authenticationState !== 'pending' + ) { + info.workspaceMcpAuthenticationServerNames.delete(serverName); + const timer = info.workspaceMcpAuthenticationTimers.get(serverName); + if (timer) clearTimeout(timer); + info.workspaceMcpAuthenticationTimers.delete(serverName); + } } + workspaceMcpStatusCache = + response as unknown as ServeWorkspaceMcpStatus; } - workspaceMcpStatusCache = response as unknown as ServeWorkspaceMcpStatus; + return response as unknown as T; + }); + }; + + const expireWorkspaceMcpAuthentication = async ( + info: ChannelInfo, + serverName: string, + timer: NodeJS.Timeout, + ): Promise => { + if ( + info.isDying || + liveChannelInfo() !== info || + info.workspaceMcpAuthenticationTimers.get(serverName) !== timer + ) { + return; } - return response as unknown as T; + try { + await requestWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + () => undefined, + {}, + new Set([serverName]), + ); + } catch { + // Failure to observe completion is not proof that uncancellable auth + // work stopped. Retiring its owning channel is the safe drain. + } + if ( + info.isDying || + liveChannelInfo() !== info || + info.workspaceMcpAuthenticationTimers.get(serverName) !== timer || + !info.workspaceMcpAuthenticationServerNames.has(serverName) + ) { + return; + } + await killChannelWithLog( + info, + `workspace MCP authentication timeout for ${serverName}`, + ); }; /** @@ -6862,9 +7083,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { async function requestSessionTranscriptPage( req: BridgeSessionTranscriptPageRequest, ): Promise { - const info = await ensureChannel(); try { - const response = await withWorkspaceControl(info, () => + const response = await withEnsuredWorkspaceControl((info) => withTimeout( Promise.race([ info.connection.extMethod( @@ -6883,10 +7103,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new SessionNotFoundError(req.sessionId); } throw err; - } finally { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'session transcript'); - } } } @@ -7609,7 +7825,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { 'qwen-code.daemon.acp_channel.id': channel.id, 'session.id': req.sessionId, }); - if (hasNoChannelWork(channel)) { + if (hasNoChannelWork(channel, { ignoreRestoreId: req.sessionId })) { void killChannelWithLog( channel, `abandoned session/${action} cleanup`, @@ -7771,7 +7987,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // the configured idle policy instead of forcing a cold respawn on // the strength of a timeout it already recovered from. restoreChannel.unsettledAbandonedRestores.add(req.sessionId); - const channelWasEmpty = hasNoChannelWork(restoreChannel); + const channelWasEmpty = hasNoChannelWork(restoreChannel, { + ignoreRestoreId: req.sessionId, + }); telemetry.event('session.restore.public_result', { 'qwen-code.daemon.session_restore.action': action, 'qwen-code.daemon.session_restore.result': 'timeout', @@ -7837,13 +8055,22 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (err instanceof SessionRestoreTimeoutError) throw err; restoreEvents.close(); if (isAcpSessionResourceNotFound(err, req.sessionId)) { + if ( + !ci.isDying && + hasNoChannelWork(ci, { ignoreRestoreId: req.sessionId }) + ) { + await startIdleTimer(ci, `session ${action} not found`); + } throw new SessionNotFoundError(req.sessionId); } - ci.emptyReapPending = hasNoChannelWork(ci, { - ignoreRestoreId: req.sessionId, - }); - if (ci.emptyReapPending) { - ci.isDying = true; + await retireChannelOnTimeout(ci, err, `session ${action} timeout`); + if (!ci.isDying) { + ci.emptyReapPending = hasNoChannelWork(ci, { + ignoreRestoreId: req.sessionId, + }); + if (ci.emptyReapPending) { + ci.isDying = true; + } } throw err; } @@ -8144,7 +8371,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }); removedRestoreEntry = true; } - if (removedRestoreEntry && ci && hasNoChannelWork(ci)) { + if ( + removedRestoreEntry && + ci && + hasNoChannelWork(ci, { ignoreRestoreId: req.sessionId }) + ) { ci.emptyReapPending = true; ci.isDying = true; } @@ -8205,6 +8436,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const current = inFlightRestores.get(req.sessionId); if (current?.settlementPromise === settlementPromise) { inFlightRestores.delete(req.sessionId); + // Delete BEFORE settling: `hasNoChannelWork` counts in-flight + // restores as channel work, so this restore's own entry would + // otherwise block the reap of a channel it left empty. + void settleReleasedRuntimeWork('session restore', false); } }); return await promise; @@ -8356,15 +8591,26 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }); if (!agentSessionClosed) { try { - await telemetry.withSpan( - 'session.close.cancel_active_prompt', - { - 'qwen-code.daemon.bridge.operation': - 'session.close.cancel_active_prompt', - 'session.id': sessionId, - }, - async () => await entry.connection.cancel({ sessionId }), - ); + const cancelActivePrompt = () => + telemetry.withSpan( + 'session.close.cancel_active_prompt', + { + 'qwen-code.daemon.bridge.operation': + 'session.close.cancel_active_prompt', + 'session.id': sessionId, + }, + async () => + await withTimeout( + entry.connection.cancel({ sessionId }), + initTimeoutMs, + 'closeSession cancel', + ), + ); + if (ci) { + await withWorkspaceControl(ci, cancelActivePrompt); + } else { + await cancelActivePrompt(); + } } catch { /* no active prompt or session already torn down */ } @@ -8554,7 +8800,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { hardCapBytes: JOURNAL_GROWTH_HARD_CAP_BYTES, } : null, - channelIdleTimeoutMs: resolvedChannelIdleTimeoutMs(), + channelIdleTimeoutMs: configuredChannelIdleTimeoutMs(), sessionIdleTimeoutMs, }, sessionCount: byId.size, @@ -8692,6 +8938,49 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return !!liveChannelInfo(); }, + getWorkspaceRuntimeLifecycleSnapshot() { + const info = liveChannelInfo(); + const runtimeLive = info !== undefined; + const sourceRuntimeEpoch = runtimeEpochSource.current(); + if ( + !Number.isSafeInteger(sourceRuntimeEpoch) || + sourceRuntimeEpoch < runtimeEpoch + ) { + throw new Error( + `Runtime epoch source regressed (local=${runtimeEpoch}, current=${sourceRuntimeEpoch}).`, + ); + } + const starting = inFlightChannelSpawn !== undefined; + const stopping = Array.from(aliveChannels).some( + (candidate) => candidate.isDying, + ); + const reservedWork = + runtimeOperationReservations > 0 || + inFlightSpawns.size > 0 || + inFlightRestores.size > 0 || + abandonedNewSessionSettlements.size > 0 || + pendingKeepAliveDeadlines.size > 0; + const activeWork = + starting || + stopping || + reservedWork || + (info !== undefined && !hasNoChannelWork(info)); + return { + state: !runtimeLive + ? stopping + ? 'stopping' + : starting + ? 'starting' + : 'cold' + : activeWork + ? 'active' + : 'idle', + runtimeLive, + runtimeEpoch: runtimeLive ? runtimeEpoch : sourceRuntimeEpoch, + activeWork, + }; + }, + get pendingPermissionCount() { return permissionMediator.pendingCount; }, @@ -10204,12 +10493,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const branchResult = ( concurrentSideTask ? Promise.resolve() : entry.promptQueue ).then(async () => { - if ( - isClosingOrAuthorizingClose(entry) || - byId.get(sessionId) !== entry - ) { + const sourceCi = assertLivePromptEntry(sessionId, entry); + if (isClosingOrAuthorizingClose(entry)) { throw new SessionNotFoundError(sessionId, 'The session is closing'); } + if (entry.promptActive && !isSideTask) { + throw new BranchWhilePromptActiveError(sessionId); + } assertFreshSessionsAvailable(); let admission: ReturnType | undefined; @@ -10235,6 +10525,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { admissionReleased = true; releaseFreshSessionReservation(admission); }; + runtimeOperationReservations++; try { // HAZARD: dispatch the source-session mutation on the entry's // OWN connection, not `ci.connection` (the current attach @@ -10262,30 +10553,33 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // transport-closed race rejects only when the channel exits — a // branch whose channel died cannot be observed or delivered anyway — // so a slow-but-alive fork still waits for its real outcome. - let result: { - newSessionId: string; - title?: string; - displayName?: string; - }; - try { - result = (await Promise.race([ - mutation, - getTransportClosedReject(entry), - ])) as typeof result; - } catch (err) { - const data = (err as { data?: unknown })?.data; - if ( - !isSideTask && - data && - typeof data === 'object' && - (data as { errorKind?: unknown }).errorKind === 'session_busy' - ) { - const msg = - (err as { message?: string })?.message ?? 'Branch failed'; - throw new SessionBusyError(sessionId, msg); + const result = await withWorkspaceControl(sourceCi, async () => { + let settled: { + newSessionId: string; + title?: string; + displayName?: string; + }; + try { + settled = (await Promise.race([ + mutation, + getTransportClosedReject(entry), + ])) as typeof settled; + } catch (err) { + const data = (err as { data?: unknown })?.data; + if ( + !isSideTask && + data && + typeof data === 'object' && + (data as { errorKind?: unknown }).errorKind === 'session_busy' + ) { + const msg = + (err as { message?: string })?.message ?? 'Branch failed'; + throw new SessionBusyError(sessionId, msg); + } + throw err; } - throw err; - } + return settled; + }); if (!result || typeof result.newSessionId !== 'string') { throw new Error( @@ -10362,24 +10656,26 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); try { if (!ci.isDying) { - await withTimeout( - Promise.race([ - ci.connection.extMethod( - SERVE_CONTROL_EXT_METHODS.sessionClose, - { - sessionId: result.newSessionId, - cwd: boundWorkspace, - drainTimeoutMs: - sessionCloseDrainBudgetMs(initTimeoutMs), - }, - ), - channelUnavailableReject( - ci.channel, - 'during branchSession cleanup', - ), - ]), - initTimeoutMs, - 'branchSession cleanup', + await withWorkspaceControl(ci, () => + withTimeout( + Promise.race([ + ci.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionClose, + { + sessionId: result.newSessionId, + cwd: boundWorkspace, + drainTimeoutMs: + sessionCloseDrainBudgetMs(initTimeoutMs), + }, + ), + channelUnavailableReject( + ci.channel, + 'during branchSession cleanup', + ), + ]), + initTimeoutMs, + 'branchSession cleanup', + ), ); } } catch (cleanupErr) { @@ -10442,6 +10738,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }; } finally { releaseAdmissionOnce(); + await releaseRuntimeOperationReservation('session branch'); } }); if (!concurrentSideTask) { @@ -10498,80 +10795,87 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // 1. cd waits for any in-flight prompt to complete // 2. Subsequent prompts wait for cd to complete (prevents stale config.cwd) const cdPromise = entry.promptQueue.then(async () => { - if (entry.promptActive) { - throw new CdWhilePromptActiveError(sessionId); - } + const ci = assertLivePromptEntry(sessionId, entry); + runtimeOperationReservations++; + try { + if (entry.promptActive) { + throw new CdWhilePromptActiveError(sessionId); + } - assertLivePromptEntry(sessionId, entry); - const raw = await Promise.race([ - entry.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionCd, { - sessionId, - path: req.path, - ...(req.allowedRoots ? { allowedRoots: req.allowedRoots } : {}), - ...(req.managedRelocation - ? { managedRelocation: req.managedRelocation } - : {}), - ...(req.conversationDirectoryExpectation - ? { - conversationDirectoryExpectation: - req.conversationDirectoryExpectation, - } - : {}), - }), - getTransportClosedReject(entry), - ]); - const extResult = raw as { - previousCwd: string; - newCwd: string; - warnings: string[]; - }; - if ( - typeof extResult?.previousCwd !== 'string' || - typeof extResult?.newCwd !== 'string' || - !Array.isArray(extResult?.warnings) - ) { - throw new Error( - `changeSessionCwd: unexpected response shape from agent: ${JSON.stringify(raw)}`, + const raw = await withWorkspaceControl(ci, () => + Promise.race([ + entry.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionCd, { + sessionId, + path: req.path, + ...(req.allowedRoots ? { allowedRoots: req.allowedRoots } : {}), + ...(req.managedRelocation + ? { managedRelocation: req.managedRelocation } + : {}), + ...(req.conversationDirectoryExpectation + ? { + conversationDirectoryExpectation: + req.conversationDirectoryExpectation, + } + : {}), + }), + getTransportClosedReject(entry), + ]), ); - } - if ( - isReservedStandaloneSessionSourceType(entry.sourceType) && - (req.conversationDirectoryExpectation === undefined || - extResult.newCwd !== - req.conversationDirectoryExpectation.child.canonicalPath) - ) { - throw standaloneWorkingDirectoryMissingError(); - } - - // State update inside the queue lambda — always executes when - // the extMethod settles, regardless of caller timeout. - entry.effectiveCwd = extResult.newCwd; - if ( - isReservedStandaloneSessionSourceType(entry.sourceType) && - req.conversationDirectoryExpectation !== undefined - ) { - entry.artifactWorkspaceReady = false; - entry.managedConversationBinding = { - expectation: req.conversationDirectoryExpectation, - released: false, + const extResult = raw as { + previousCwd: string; + newCwd: string; + warnings: string[]; }; + if ( + typeof extResult?.previousCwd !== 'string' || + typeof extResult?.newCwd !== 'string' || + !Array.isArray(extResult?.warnings) + ) { + throw new Error( + `changeSessionCwd: unexpected response shape from agent: ${JSON.stringify(raw)}`, + ); + } + + if ( + isReservedStandaloneSessionSourceType(entry.sourceType) && + (req.conversationDirectoryExpectation === undefined || + extResult.newCwd !== + req.conversationDirectoryExpectation.child.canonicalPath) + ) { + throw standaloneWorkingDirectoryMissingError(); + } + + // State update inside the queue lambda — always executes when + // the extMethod settles, regardless of caller timeout. + entry.effectiveCwd = extResult.newCwd; + if ( + isReservedStandaloneSessionSourceType(entry.sourceType) && + req.conversationDirectoryExpectation !== undefined + ) { + entry.artifactWorkspaceReady = false; + entry.managedConversationBinding = { + expectation: req.conversationDirectoryExpectation, + released: false, + }; + } + if (extResult.previousCwd !== extResult.newCwd) { + entry.events.publish({ + type: 'session_cwd_changed', + data: { + sessionId, + previousCwd: extResult.previousCwd, + newCwd: extResult.newCwd, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } + return extResult; + } finally { + await releaseRuntimeOperationReservation('session cwd change'); } - if (extResult.previousCwd !== extResult.newCwd) { - entry.events.publish({ - type: 'session_cwd_changed', - data: { - sessionId, - previousCwd: extResult.previousCwd, - newCwd: extResult.newCwd, - }, - ...(originatorClientId ? { originatorClientId } : {}), - }); - } - return extResult; }); - // Queue tail tied to the raw extMethod settlement — subsequent - // operations wait for the actual cd to finish, not the timeout. + // Queue tail follows the physical cd attempt, including its deadline. entry.promptQueue = cdPromise.then( () => undefined, () => undefined, @@ -11166,24 +11470,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ) { const startsWorkspaceChannel = method === SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart; - const info = startsWorkspaceChannel - ? await ensureChannel() - : liveChannelInfo(); - if (!info) throw new SessionNotFoundError(`workspace-command:${method}`); - try { + const invoke = async (info: ChannelInfo) => { const timeout = invokeOpts?.timeoutMs ?? initTimeoutMs; - const invoke = () => - withTimeout( - Promise.race([ - info.connection.extMethod(method, params ?? {}), - getChannelClosedReject(info), - ]), - timeout, - method, - ); - const response = startsWorkspaceChannel - ? await withWorkspaceControl(info, invoke) - : await invoke(); + const response = await withTimeout( + Promise.race([ + info.connection.extMethod(method, params ?? {}), + getChannelClosedReject(info), + ]), + timeout, + method, + ); if ( method === SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart && typeof params?.['serverName'] === 'string' @@ -11201,115 +11497,89 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); } return response as T; - } finally { - if (startsWorkspaceChannel && hasNoChannelWork(info)) { - await startIdleTimer(info, 'workspace MCP restart'); - } + }; + if (startsWorkspaceChannel) { + return await withEnsuredWorkspaceControl(invoke); } + const info = liveChannelInfo(); + if (!info) throw new SessionNotFoundError(`workspace-command:${method}`); + return await withWorkspaceControl(info, () => invoke(info)); }, async isWorkspaceMemoryRememberAvailable(): Promise { - const info = await ensureChannel(); - try { - const response = await withWorkspaceControl(info, () => - withTimeout( - Promise.race([ - info.connection.extMethod( - SERVE_CONTROL_EXT_METHODS.workspaceMemoryRememberAvailability, - { cwd: boundWorkspace }, - ), - getChannelClosedReject(info), - ]), - initTimeoutMs, - SERVE_CONTROL_EXT_METHODS.workspaceMemoryRememberAvailability, - ), + return await withEnsuredWorkspaceControl(async (info) => { + const response = await withTimeout( + Promise.race([ + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMemoryRememberAvailability, + { cwd: boundWorkspace }, + ), + getChannelClosedReject(info), + ]), + initTimeoutMs, + SERVE_CONTROL_EXT_METHODS.workspaceMemoryRememberAvailability, ); return ( response !== null && typeof response === 'object' && (response as Record)['available'] === true ); - } finally { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'workspace memory remember availability'); - } - } + }); }, async runWorkspaceMemoryRemember( request: BridgeWorkspaceMemoryRememberRequest, ): Promise { - const info = await ensureChannel(); - try { - const response = await withWorkspaceControl(info, () => - withTimeout( - Promise.race([ - info.connection.extMethod( - SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, - { ...request, cwd: boundWorkspace }, - ), - getChannelClosedReject(info), - ]), - WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS, - SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, - ), + return await withEnsuredWorkspaceControl(async (info) => { + const response = await withTimeout( + Promise.race([ + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, + { ...request, cwd: boundWorkspace }, + ), + getChannelClosedReject(info), + ]), + WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS, + SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, ); return parseWorkspaceMemoryRememberResult(response); - } finally { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'workspace memory remember'); - } - } + }); }, async runWorkspaceMemoryForget( request: BridgeWorkspaceMemoryForgetRequest, ): Promise { - const info = await ensureChannel(); - try { - const response = await withWorkspaceControl(info, () => - withTimeout( - Promise.race([ - info.connection.extMethod( - SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, - { ...request, cwd: boundWorkspace }, - ), - getChannelClosedReject(info), - ]), - WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS, - SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, - ), + return await withEnsuredWorkspaceControl(async (info) => { + const response = await withTimeout( + Promise.race([ + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, + { ...request, cwd: boundWorkspace }, + ), + getChannelClosedReject(info), + ]), + WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS, + SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, ); return parseWorkspaceMemoryForgetResult(response); - } finally { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'workspace memory forget'); - } - } + }); }, async runWorkspaceMemoryDream(): Promise { - const info = await ensureChannel(); - try { - const response = await withWorkspaceControl(info, () => - withTimeout( - Promise.race([ - info.connection.extMethod( - SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, - { cwd: boundWorkspace }, - ), - getChannelClosedReject(info), - ]), - WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS, - SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, - ), + return await withEnsuredWorkspaceControl(async (info) => { + const response = await withTimeout( + Promise.race([ + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, + { cwd: boundWorkspace }, + ), + getChannelClosedReject(info), + ]), + WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS, + SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, ); return parseWorkspaceMemoryDreamResult(response); - } finally { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'workspace memory dream'); - } - } + }); }, async getWorkspaceMcpToolsStatus(serverName) { @@ -11644,7 +11914,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); bootstrapRefreshConnections.add(entry.connection); try { - await refreshSession(entry, refreshBootstrap); + await withWorkspaceControl(info, () => + refreshSession(entry, refreshBootstrap), + ); return { refreshed: 1, failed: 0, entry, refreshBootstrap }; } catch (err) { writeServeDebugLine( @@ -11666,8 +11938,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { result.entry.connection === failedBootstrap.entry.connection, ); if (!retry) return; + const info = channelInfoForEntry(retry.entry); + if (!info || info.isDying) return; try { - await refreshSession(retry.entry, true); + await withWorkspaceControl(info, () => + refreshSession(retry.entry, true), + ); } catch (err) { writeServeDebugLine( `refreshExtensions: bootstrap retry via session ${retry.entry.sessionId} failed: ` + @@ -13021,88 +13297,73 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }, async manageMcpServer(serverName, action, originatorClientId) { - const info = await ensureChannel(); - try { - return await withWorkspaceControl(info, async () => { - const timeout = - action === 'authenticate' - ? MCP_OAUTH_TIMEOUT_MS - : MCP_RESTART_TIMEOUT_MS; - const response = (await Promise.race([ - withTimeout( - info.connection.extMethod( - SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, - { serverName, action, originatorClientId }, - ), - timeout, + return await withEnsuredWorkspaceControl(async (info) => { + const timeout = + action === 'authenticate' + ? MCP_OAUTH_TIMEOUT_MS + : MCP_RESTART_TIMEOUT_MS; + const response = (await Promise.race([ + withTimeout( + info.connection.extMethod( SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, + { serverName, action, originatorClientId }, ), - getChannelClosedReject(info), - ])) as { - serverName: string; - action: - | 'approve' - | 'enable' - | 'disable' - | 'authenticate' - | 'clear-auth'; - ok: true; - changed?: boolean; - messages?: string[]; - authUrl?: string; - pending?: boolean; - }; - if (action === 'authenticate' && response.pending) { - info.workspaceMcpAuthenticationServerNames.add(serverName); - const previousTimer = - info.workspaceMcpAuthenticationTimers.get(serverName); - if (previousTimer) clearTimeout(previousTimer); - const timer = setTimeout(() => { - info.workspaceMcpAuthenticationServerNames.delete(serverName); - info.workspaceMcpAuthenticationTimers.delete(serverName); - if (hasNoChannelWork(info)) { - void startIdleTimer( - info, - 'workspace MCP authentication timeout', - ); - } - }, MCP_OAUTH_TIMEOUT_MS); - timer.unref(); - info.workspaceMcpAuthenticationTimers.set(serverName, timer); - } - invalidateWorkspaceMcpDetailCache(serverName); - await requestWorkspaceStatus( - SERVE_STATUS_EXT_METHODS.workspaceMcp, - () => { - throw new BridgeChannelClosedError( - 'workspace MCP management status refresh', - ); - }, - {}, - new Set([serverName]), - ); - broadcastWorkspaceEvent({ - type: 'mcp_server_changed', - data: { - serverName: response.serverName, - action: response.action, - originatorClientId, - }, - ...(originatorClientId ? { originatorClientId } : {}), - }); - return response; - }); - } finally { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'workspace MCP management'); + timeout, + SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, + ), + getChannelClosedReject(info), + ])) as { + serverName: string; + action: + | 'approve' + | 'enable' + | 'disable' + | 'authenticate' + | 'clear-auth'; + ok: true; + changed?: boolean; + messages?: string[]; + authUrl?: string; + pending?: boolean; + }; + if (action === 'authenticate' && response.pending) { + info.workspaceMcpAuthenticationServerNames.add(serverName); + const previousTimer = + info.workspaceMcpAuthenticationTimers.get(serverName); + if (previousTimer) clearTimeout(previousTimer); + const timer = setTimeout(() => { + void expireWorkspaceMcpAuthentication(info, serverName, timer); + }, MCP_OAUTH_TIMEOUT_MS); + timer.unref(); + info.workspaceMcpAuthenticationTimers.set(serverName, timer); } - } + invalidateWorkspaceMcpDetailCache(serverName); + await requestWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + () => { + throw new BridgeChannelClosedError( + 'workspace MCP management status refresh', + ); + }, + {}, + new Set([serverName]), + ); + broadcastWorkspaceEvent({ + type: 'mcp_server_changed', + data: { + serverName: response.serverName, + action: response.action, + originatorClientId, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + return response; + }); }, async initializeWorkspaceMcp() { - const info = await ensureChannel(); - info.workspaceMcpDiscoveryRequested = true; - try { + return await withEnsuredWorkspaceControl(async (info) => { + info.workspaceMcpDiscoveryRequested = true; const result = (await Promise.race([ withTimeout( info.connection.extMethod( @@ -13118,17 +13379,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { beginWorkspaceMcpDiscovery(info); } return result; - } finally { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'workspace MCP initialization'); - } - } + }); }, async reloadWorkspaceMcp(options) { - const info = await ensureChannel(); - info.workspaceMcpDiscoveryRequested = true; - try { + return await withEnsuredWorkspaceControl(async (info) => { + info.workspaceMcpDiscoveryRequested = true; const result = (await Promise.race([ withTimeout( info.connection.extMethod( @@ -13144,11 +13400,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { beginWorkspaceMcpDiscovery(info); } return result; - } finally { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'workspace MCP reload'); - } - } + }); }, async generateWorkspaceAgent(description, _originatorClientId) { @@ -13156,21 +13408,25 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (!info) { throw new SessionNotFoundError('agents:generate'); } - return (await Promise.race([ - withTimeout( - info.connection.extMethod( - SERVE_CONTROL_EXT_METHODS.workspaceAgentGenerate, - { description }, - ), - MCP_RESTART_TIMEOUT_MS, - SERVE_CONTROL_EXT_METHODS.workspaceAgentGenerate, - ), - getChannelClosedReject(info), - ])) as { - name: string; - description: string; - systemPrompt: string; - }; + return await withWorkspaceControl( + info, + async () => + (await Promise.race([ + withTimeout( + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceAgentGenerate, + { description }, + ), + MCP_RESTART_TIMEOUT_MS, + SERVE_CONTROL_EXT_METHODS.workspaceAgentGenerate, + ), + getChannelClosedReject(info), + ])) as { + name: string; + description: string; + systemPrompt: string; + }, + ); }, generateWorkspaceContent(prompt, signal, _originatorClientId) { @@ -13203,11 +13459,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return queue; } + runtimeOperationReservations++; void (async () => { - let info: ChannelInfo | undefined; try { const channelInfo = await ensureChannel(); - info = channelInfo; request.connection = channelInfo.connection; await withWorkspaceControl(channelInfo, async () => { if (request.settled) return; @@ -13258,9 +13513,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { request.settled = true; signal.removeEventListener('abort', cancel); workspaceGenerationRequests.delete(requestId); - if (info && hasNoChannelWork(info) && !info.isDying) { - await startIdleTimer(info, 'workspace generation'); - } + await releaseRuntimeOperationReservation('workspace generation'); } })().catch(() => undefined); @@ -13293,35 +13546,36 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { skipped: true; reason: 'budget_warning_only' | 'runtime_name_conflict'; }; - const response = (await Promise.race([ - withTimeout( - info.connection.extMethod( + return await withWorkspaceControl(info, async () => { + const response = (await Promise.race([ + withTimeout( + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd, + { name, config, originatorClientId }, + ), + MCP_RESTART_SERVER_DEADLINE_MS, SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd, - { name, config, originatorClientId }, ), - MCP_RESTART_SERVER_DEADLINE_MS, - SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeAdd, - ), - getChannelClosedReject(info), - ])) as AddOk | AddSkip; - // Emit event on success (non-skip) - const addSkipped = (response as { skipped?: boolean }).skipped === true; - if (!addSkipped) { - const ok = response as AddOk; - broadcastWorkspaceEvent({ - type: 'mcp_server_added', - data: { - name: ok.name, - transport: ok.transport, - replaced: ok.replaced, - shadowedSettings: ok.shadowedSettings, - toolCount: ok.toolCount, - originatorClientId: ok.originatorClientId, - }, - ...(originatorClientId ? { originatorClientId } : {}), - }); - } - return response; + getChannelClosedReject(info), + ])) as AddOk | AddSkip; + const addSkipped = (response as { skipped?: boolean }).skipped === true; + if (!addSkipped) { + const ok = response as AddOk; + broadcastWorkspaceEvent({ + type: 'mcp_server_added', + data: { + name: ok.name, + transport: ok.transport, + replaced: ok.replaced, + shadowedSettings: ok.shadowedSettings, + toolCount: ok.toolCount, + originatorClientId: ok.originatorClientId, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } + return response; + }); }, async removeRuntimeMcpServer(name, originatorClientId) { @@ -13342,33 +13596,34 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { originatorClientId: string; }; type RemoveSkip = { name: string; skipped: true; reason: 'not_present' }; - const response = (await Promise.race([ - withTimeout( - info.connection.extMethod( + return await withWorkspaceControl(info, async () => { + const response = (await Promise.race([ + withTimeout( + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeRemove, + { name, originatorClientId }, + ), + MCP_RESTART_SERVER_DEADLINE_MS, SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeRemove, - { name, originatorClientId }, ), - MCP_RESTART_SERVER_DEADLINE_MS, - SERVE_CONTROL_EXT_METHODS.workspaceMcpRuntimeRemove, - ), - getChannelClosedReject(info), - ])) as RemoveOk | RemoveSkip; - // Emit event on success (non-skip) - const removeSkipped = - (response as { skipped?: boolean }).skipped === true; - if (!removeSkipped) { - const ok = response as RemoveOk; - broadcastWorkspaceEvent({ - type: 'mcp_server_removed', - data: { - name: ok.name, - wasShadowingSettings: ok.wasShadowingSettings, - originatorClientId: ok.originatorClientId, - }, - ...(originatorClientId ? { originatorClientId } : {}), - }); - } - return response; + getChannelClosedReject(info), + ])) as RemoveOk | RemoveSkip; + const removeSkipped = + (response as { skipped?: boolean }).skipped === true; + if (!removeSkipped) { + const ok = response as RemoveOk; + broadcastWorkspaceEvent({ + type: 'mcp_server_removed', + data: { + name: ok.name, + wasShadowingSettings: ok.wasShadowingSettings, + originatorClientId: ok.originatorClientId, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } + return response; + }); }, async addSessionRuntimeMcpServer( @@ -13731,7 +13986,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ) : Promise.resolve(); const teardownResults = await Promise.allSettled([ - ...channels.map((ci) => ci.channel.kill()), + ...channels.map((ci) => + terminateChannel(ci.channel, 'bridge shutdown'), + ), ...[...byId.values()].map((entry) => entry.attachments.close()), ...inFlightSessionAwaits, ...inFlightRestoreAwaits, @@ -13752,19 +14009,53 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return shutdownPromise; }, - async preheat() { - if (shuttingDown) return; - await telemetry.withSpan( - 'channel.preheat', - { 'qwen-code.daemon.bridge.operation': 'channel.preheat' }, - async () => { - const ci = await ensureChannel(); - const idleMs = resolvedChannelIdleTimeoutMs(); - if (idleMs > 0 && hasNoChannelWork(ci)) { - await startIdleTimer(ci); - } - }, - ); + async preheat(options) { + if (shuttingDown) { + throw new Error('AcpSessionBridge is shutting down'); + } + runtimeOperationReservations++; + const rawKeepAliveMs = options?.keepAliveMs; + const keepAliveMs = + rawKeepAliveMs !== undefined && + Number.isFinite(rawKeepAliveMs) && + rawKeepAliveMs > 0 + ? Math.min(rawKeepAliveMs, 2_147_483_647) + : undefined; + const pendingKeepAliveToken = + keepAliveMs === undefined ? undefined : Symbol(); + if (pendingKeepAliveToken && keepAliveMs !== undefined) { + pendingKeepAliveDeadlines.set( + pendingKeepAliveToken, + Date.now() + keepAliveMs, + ); + } + try { + await telemetry.withSpan( + 'channel.preheat', + { 'qwen-code.daemon.bridge.operation': 'channel.preheat' }, + async () => { + await ensureChannel(); + if (keepAliveMs !== undefined) { + keepAliveUntil = Math.max( + keepAliveUntil, + Date.now() + keepAliveMs, + ); + } + }, + ); + } finally { + if (pendingKeepAliveToken) { + pendingKeepAliveDeadlines.delete(pendingKeepAliveToken); + } + runtimeOperationReservations = Math.max( + 0, + runtimeOperationReservations - 1, + ); + await settleReleasedRuntimeWork( + 'channel preheat', + resolvedChannelIdleTimeoutMs() > 0, + ); + } }, }; diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index 9dbc26269f4..062f14f2dff 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -89,6 +89,16 @@ export type BridgeSessionLifecycle = ( event: BridgeSessionLifecycleEvent, ) => void; +/** + * Allocates monotonically increasing Channel epochs for one canonical + * workspace. Daemon hosts reuse the same source across runtime replacement; + * standalone Bridge users may omit it and receive a Bridge-local source. + */ +export interface BridgeRuntimeEpochSource { + current(): number; + allocate(): number; +} + /** * Trusted child-to-daemon request made immediately before a tool executor. * `sessionId` and `promptId` are revalidated by BridgeClient against its @@ -236,6 +246,8 @@ export interface BridgeOptions { sessionScope?: 'single' | 'thread'; /** Channel factory; defaults to spawning `qwen --acp` as a child process. */ channelFactory?: ChannelFactory; + /** Workspace-scoped epoch source shared across Bridge replacement. */ + runtimeEpochSource?: BridgeRuntimeEpochSource; /** How long to wait for the child's `initialize` reply before giving up. */ initializeTimeoutMs?: number; /** @@ -553,11 +565,9 @@ export interface BridgeOptions { */ onDiagnosticLine?: DiagnosticLineSink; /** - * Milliseconds to keep the ACP child alive after the last session - * closes. When a new session arrives during the idle window, the - * warm channel is reused without a cold start. `0` (default) kills - * the channel immediately (current behavior). The timer is `.unref()`'d - * so it does not prevent daemon exit. + * Keeps the ACP child alive after the last session and workspace operation + * drain. `0` or unset kills it immediately. Timers are `.unref()`'d so they + * do not prevent daemon exit. */ channelIdleTimeoutMs?: number; /** diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 87c46797406..fc47f4a6d4a 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -687,6 +687,13 @@ export interface BridgePendingUserQuestionInteraction { options: BridgePendingInteractionOption[]; } +export interface BridgeWorkspaceRuntimeLifecycleSnapshot { + state: 'cold' | 'starting' | 'active' | 'idle' | 'stopping'; + runtimeLive: boolean; + runtimeEpoch: number; + activeWork: boolean; +} + export type BridgePendingInteraction = | BridgePendingPermissionInteraction | BridgePendingUserQuestionInteraction; @@ -2233,6 +2240,13 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { */ isChannelLive(): boolean; + /** + * Atomic physical lifecycle snapshot. Optional only for compatibility with + * older injected/embedded Bridge implementations; hosts must not advertise + * workspace runtime control when it is absent. + */ + getWorkspaceRuntimeLifecycleSnapshot?(): BridgeWorkspaceRuntimeLifecycleSnapshot; + /** Number of sessions with an active prompt. */ readonly activePromptCount: number; @@ -2349,7 +2363,7 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { * cold-start latency. Fire-and-forget; failures are logged and the * first session falls back to lazy spawn. */ - preheat(): Promise; + preheat(options?: { keepAliveMs?: number }): Promise; } export interface BridgeShutdownOptions { diff --git a/packages/acp-bridge/src/channel.ts b/packages/acp-bridge/src/channel.ts index 6d7d47eb8d0..1f22c670520 100644 --- a/packages/acp-bridge/src/channel.ts +++ b/packages/acp-bridge/src/channel.ts @@ -73,4 +73,5 @@ export interface AcpChannelExitInfo { export type ChannelFactory = ( workspaceCwd: string, childEnvOverrides?: Readonly>, + signal?: AbortSignal, ) => Promise; diff --git a/packages/acp-bridge/src/spawnChannel.test.ts b/packages/acp-bridge/src/spawnChannel.test.ts index 2228d23c6cf..aaf361a715d 100644 --- a/packages/acp-bridge/src/spawnChannel.test.ts +++ b/packages/acp-bridge/src/spawnChannel.test.ts @@ -32,7 +32,7 @@ * Each branch listed below is now regression-guarded by an assertion. */ -import { EventEmitter } from 'node:events'; +import { EventEmitter, getEventListeners } from 'node:events'; import type { ChildProcess } from 'node:child_process'; import { PassThrough } from 'node:stream'; import { ClientSideConnection } from '@agentclientprotocol/sdk'; @@ -584,6 +584,38 @@ describe('createSpawnChannelFactory env policy', () => { signalCode: null, }); }); + + it('force-kills the child when the startup signal aborts after spawn', async () => { + const child = createFakeChildProcess(); + mockSpawn.mockReturnValue(child); + const controller = new AbortController(); + + const channel = await createSpawnChannelFactory()( + '/tmp/project', + undefined, + controller.signal, + ); + + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(1); + controller.abort(); + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + + await expect(channel.exited).resolves.toEqual({ + exitCode: null, + signalCode: 'SIGKILL', + }); + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0); + }); + + it('rejects without spawning when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(new Error('startup cancelled')); + + await expect( + createSpawnChannelFactory()('/tmp/project', undefined, controller.signal), + ).rejects.toThrow('startup cancelled'); + expect(mockSpawn).not.toHaveBeenCalled(); + }); }); describe('createSpawnChannelFactory child-heap observation', () => { diff --git a/packages/acp-bridge/src/spawnChannel.ts b/packages/acp-bridge/src/spawnChannel.ts index 97a850b32e8..369eb6bda76 100644 --- a/packages/acp-bridge/src/spawnChannel.ts +++ b/packages/acp-bridge/src/spawnChannel.ts @@ -434,7 +434,12 @@ export function createSpawnChannelFactory( ): ChannelFactory { if (options.pipeLimits) validateNdJsonStreamLimits(options.pipeLimits); const processRegistry = options.processRegistry ?? new ProcessRegistry(); - return async (workspaceCwd, childEnvOverrides) => { + return async (workspaceCwd, childEnvOverrides, signal) => { + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new Error('ACP channel spawn was aborted'); + } const sourceEnv = options.sourceEnv ?? process.env; const cliEntry = sourceEnv['QWEN_CLI_ENTRY'] || process.argv[1]; if (!cliEntry) { @@ -491,6 +496,24 @@ export function createSpawnChannelFactory( throw error; } const trackedChild = reservation.attach(child, { ownsProcessTree: true }); + const abortSpawn = () => { + try { + trackedChild.killSync(); + } catch { + // The child may have exited between the abort and the signal. + } + }; + signal?.addEventListener('abort', abortSpawn, { once: true }); + void trackedChild.exited.then( + () => signal?.removeEventListener('abort', abortSpawn), + () => signal?.removeEventListener('abort', abortSpawn), + ); + if (signal?.aborted) { + abortSpawn(); + throw signal.reason instanceof Error + ? signal.reason + : new Error('ACP channel spawn was aborted'); + } // Forward child stderr to the daemon's stderr line-by-line, with a // `[serve pid=… cwd=…]` prefix on each line so operators can diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 81990d54307..fc3cc51b89a 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -10,6 +10,14 @@ import { SkillError } from '@qwen-code/qwen-code-core'; export const STATUS_SCHEMA_VERSION = 1 as const; +export interface ServeWorkspaceRuntimeStatus { + v: typeof STATUS_SCHEMA_VERSION; + workspaceCwd: string; + state: 'cold' | 'starting' | 'active' | 'idle' | 'stopping'; + runtimeLive: boolean; + runtimeEpoch: number; +} + /** * Closed enumeration of structured error categories surfaced on diagnostic * status cells. Cells produced by `/workspace/preflight`, `/workspace/env`, diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 63f96766ca9..6d2ece4f2d0 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -483,8 +483,8 @@ export const serveCommand: CommandModule = { type: 'boolean', default: true, description: - 'HTTP bridge mode: attempt to preheat one primary `qwen --acp` child; trusted ' + - 'secondaries start one on demand. Stage 2 native in-process mode is ' + + 'HTTP bridge mode: attempt to preheat the primary `qwen --acp` child; ' + + 'trusted secondaries start one on demand. Stage 2 native in-process mode is ' + 'not yet implemented; this flag will become opt-in then.', }) .option('memory-budget-mb', { @@ -582,7 +582,7 @@ export const serveCommand: CommandModule = { .option('channel-idle-timeout-ms', { type: 'number', description: - 'Milliseconds to keep ACP child alive after last session closes. ' + + 'Compatibility auto-reap delay for an idle workspace ACP child. ' + '0 or unset = immediate kill (default).', }) .option('initialize-timeout-ms', { diff --git a/packages/cli/src/serve/acp-session-bridge.ts b/packages/cli/src/serve/acp-session-bridge.ts index 8a2dae6ea26..ce29320c783 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -69,6 +69,7 @@ export type { BridgeSessionLifecycle, BridgeSessionLifecycleEvent, BridgeOptions, + BridgeRuntimeEpochSource, DaemonStatusProvider, } from '@qwen-code/acp-bridge/bridgeOptions'; @@ -105,6 +106,7 @@ export type { BridgeDaemonStatusLimits, BridgeDaemonSessionDiagnostic, BridgeDaemonStatusSnapshot, + BridgeWorkspaceRuntimeLifecycleSnapshot, BridgeShutdownOptions, WorkspaceEventPublisher, WorkspaceEventBridge, diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index cf05cfaf858..3eae19146d4 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -372,6 +372,8 @@ export const SERVE_CAPABILITY_REGISTRY = { // with a display). Headless hosts omit the tag so clients hide the // Browse affordance instead of surfacing a guaranteed picker failure. native_directory_picker: { since: 'v1' }, + // Workspace-owned runtime lifecycle status and explicit on-demand startup. + workspace_runtime: { since: 'v1' }, // Workspace-qualified core REST routes under `/workspaces/:workspace/...`. // Covers core file read/write/upload, status/permissions/trust/lifecycle/MCP/tool, // memory, workspace agent CRUD, and persisted session organization surfaces. @@ -518,6 +520,7 @@ export interface AdvertiseFeatureToggles { scratchWorkspaceRegistrationAvailable?: boolean; workspaceRuntimeRemovalAvailable?: boolean; nativeDirectoryPickerAvailable?: boolean; + workspaceRuntimeAvailable?: boolean; /** * Whether the HTTP ACP surface is enabled (default on; opts out via * QWEN_SERVE_ACP_HTTP=0). Workspace-qualified ACP is only advertised when on. @@ -660,6 +663,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< 'native_directory_picker', (toggles) => toggles.nativeDirectoryPickerAvailable === true, ], + [ + 'workspace_runtime', + (toggles) => toggles.workspaceRuntimeAvailable === true, + ], [ 'workspace_qualified_acp', // The plural routes are pre-mounted for workspaces registered after app diff --git a/packages/cli/src/serve/routes/workspace-management.test.ts b/packages/cli/src/serve/routes/workspace-management.test.ts index 10e72b6906b..5c9ce041330 100644 --- a/packages/cli/src/serve/routes/workspace-management.test.ts +++ b/packages/cli/src/serve/routes/workspace-management.test.ts @@ -1991,6 +1991,34 @@ describe('DELETE /workspaces/:workspace', () => { expect(deps.workspaceRegistry.beginDrain).not.toHaveBeenCalled(); }); + it('blocks non-force removal during zero-session workspace runtime work', async () => { + const runtime = makeRuntime(REAL_DIR); + Object.assign(runtime.bridge, { + getWorkspaceRuntimeLifecycleSnapshot: () => ({ + state: 'active', + runtimeLive: true, + runtimeEpoch: 1, + activeWork: true, + }), + }); + const runtimeRemoval = createRemovalController(); + const { app } = createApp({ + workspaceRegistry: createMockRegistry([runtime]), + runtimeRemoval, + }); + + const res = await request(app).delete( + `/workspaces/${encodeURIComponent(runtime.workspaceId)}`, + ); + + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ + code: 'workspace_busy', + activity: { sessions: 0, workspaceRuntime: 1 }, + }); + expect(runtimeRemoval.beginDrain).not.toHaveBeenCalled(); + }); + it('blocks non-force removal while a Voice operation is active', async () => { const runtime = makeRuntime(REAL_DIR); const runtimeRemoval = createRemovalController(); @@ -2139,6 +2167,38 @@ describe('DELETE /workspaces/:workspace', () => { ); }); + it('cancels the runtime coordinator drain when persistence removal fails', async () => { + const runtime = makeRuntime(REAL_DIR); + Object.assign(runtime.bridge, { + preheat: vi.fn().mockResolvedValue(undefined), + getWorkspaceRuntimeLifecycleSnapshot: () => ({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + activeWork: false, + }), + }); + const runtimeRemoval = createRemovalController(); + const { app } = createApp({ + workspaceRegistry: createMockRegistry([runtime]), + runtimeRemoval, + workspaceRegistrationStore: { + removeByIds: vi.fn().mockRejectedValue(new Error('disk full')), + } as unknown as WorkspaceRegistrationStore, + }); + + const res = await request(app).delete( + `/workspaces/${encodeURIComponent(runtime.workspaceId)}`, + ); + + expect(res.status).toBe(500); + expect(res.body.code).toBe('workspace_persist_failed'); + expect(runtime.runtimeCoordinator).toBeDefined(); + await expect(runtime.runtimeCoordinator!.ensure()).resolves.toMatchObject({ + runtimeLive: true, + }); + }); + it('force-removes activity, aliases, runtime resources, and registry state', async () => { const runtime = makeRuntime(REAL_DIR, { registrationIds: ['raw-alias-a', 'raw-alias-b'], diff --git a/packages/cli/src/serve/routes/workspace-management.ts b/packages/cli/src/serve/routes/workspace-management.ts index 917761d929b..56b0ac7401c 100644 --- a/packages/cli/src/serve/routes/workspace-management.ts +++ b/packages/cli/src/serve/routes/workspace-management.ts @@ -19,6 +19,7 @@ import type { WorkspaceRegistry, WorkspaceRuntime, } from '../workspace-registry.js'; +import { getWorkspaceRuntimeCoordinatorIfSupported } from '../workspace-runtime-coordinator.js'; import { isInternalWorkspaceRuntime } from '../workspace-runtime-visibility.js'; import type { AcpHttpHandle } from '../acp-http/index.js'; import { @@ -85,6 +86,7 @@ export interface WorkspaceRemovalActivity { memoryTasks: number; channelWorkers: number; voiceSessions: number; + workspaceRuntime: number; } export interface WorkspaceRuntimeRemovalController { @@ -1245,6 +1247,11 @@ export function registerWorkspaceManagementRoutes( memoryTasks: acpActivity.memoryTasks, channelWorkers: controllerActivity.channelWorkers, voiceSessions: controllerActivity.voiceSessions, + workspaceRuntime: + getWorkspaceRuntimeCoordinatorIfSupported(runtime)?.hasActiveWork() === + true + ? 1 + : 0, }; }; const isBusy = (activity: WorkspaceRemovalActivity): boolean => @@ -1455,6 +1462,9 @@ export function registerWorkspaceManagementRoutes( let controllerDraining = false; let acpDraining = false; let removalCommitted = false; + let runtimeCoordinatorDraining = false; + const runtimeCoordinator = + getWorkspaceRuntimeCoordinatorIfSupported(runtime); const rollbackDrain = (): void => { if (removalCommitted) return; if (acpDraining) { @@ -1473,6 +1483,14 @@ export function registerWorkspaceManagementRoutes( } controllerDraining = false; } + if (runtimeCoordinatorDraining) { + try { + runtimeCoordinator?.cancelDrain(); + } catch { + // Continue rolling back the remaining gates. + } + runtimeCoordinatorDraining = false; + } if (registryDraining) { try { workspaceRegistry.cancelDrain(runtime); @@ -1553,6 +1571,7 @@ export function registerWorkspaceManagementRoutes( registryDraining = false; controllerDraining = false; acpDraining = false; + runtimeCoordinatorDraining = false; }; try { @@ -1564,6 +1583,8 @@ export function registerWorkspaceManagementRoutes( }); return; } + runtimeCoordinator?.beginDrain(); + runtimeCoordinatorDraining = runtimeCoordinator !== undefined; runtimeRemoval.beginDrain(runtime); controllerDraining = true; getAcpHandle?.()?.beginWorkspaceDrain(runtime.workspaceId); diff --git a/packages/cli/src/serve/routes/workspace-runtime.test.ts b/packages/cli/src/serve/routes/workspace-runtime.test.ts new file mode 100644 index 00000000000..fe098ea8daf --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-runtime.test.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import express, { + type NextFunction, + type Request, + type Response, +} from 'express'; +import request from 'supertest'; +import { describe, expect, it, vi } from 'vitest'; +import type { BridgeWorkspaceRuntimeLifecycleSnapshot } from '../acp-session-bridge.js'; +import { sendBridgeError } from '../server/error-response.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; +import { + registerWorkspaceQualifiedRuntimeRoutes, + registerWorkspaceRuntimeRoutes, +} from './workspace-runtime.js'; + +function createRuntime(workspaceCwd = '/workspace') { + let snapshot: BridgeWorkspaceRuntimeLifecycleSnapshot = { + state: 'cold', + runtimeLive: false, + runtimeEpoch: 0, + activeWork: false, + }; + const preheat = vi.fn(async () => { + snapshot = { + state: 'idle', + runtimeLive: true, + runtimeEpoch: snapshot.runtimeEpoch + 1, + activeWork: false, + }; + }); + return { + workspaceCwd, + workspaceId: `ws-${workspaceCwd}`, + trusted: true, + bridge: { + sessionCount: 0, + preheat, + getWorkspaceRuntimeLifecycleSnapshot: () => snapshot, + publishWorkspaceEvent: vi.fn(), + }, + } as unknown as WorkspaceRuntime; +} + +function createApp( + runtime: WorkspaceRuntime, + options: { + denyStrictMutations?: boolean; + runtimeState?: 'active' | 'transitioning'; + } = {}, +) { + const app = express(); + app.use(express.json()); + const workspaceRegistry = { + primaryEntry: { + state: options.runtimeState ?? 'active', + workspaceId: runtime.workspaceId, + workspaceCwd: runtime.workspaceCwd, + current: { runtime }, + }, + } as unknown as WorkspaceRegistry; + registerWorkspaceRuntimeRoutes(app, { + workspaceRegistry, + mutate: + (gateOptions) => (_req: Request, res: Response, next: NextFunction) => { + if (gateOptions?.strict && options.denyStrictMutations) { + res.status(401).json({ code: 'token_required' }); + return; + } + next(); + }, + safeBody: (req) => (req.body ?? {}) as Record, + sendBridgeError, + }); + return app; +} + +describe('workspace runtime routes', () => { + it('starts the primary runtime without capability selection', async () => { + const runtime = createRuntime(); + + const response = await request(createApp(runtime)).post( + '/workspace/runtime/ensure', + ); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + }); + expect(runtime.bridge.preheat).toHaveBeenCalledWith({ + keepAliveMs: 600_000, + }); + }); + + it('rejects parameters on the unified ensure route', async () => { + const runtime = createRuntime(); + + const response = await request(createApp(runtime)) + .post('/workspace/runtime/ensure') + .send({ capabilities: ['mcp'] }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe( + 'workspace_runtime_ensure_takes_no_parameters', + ); + expect(runtime.bridge.preheat).not.toHaveBeenCalled(); + }); + + it('trust-gates the primary runtime routes', async () => { + const runtime = createRuntime(); + (runtime as { trusted: boolean }).trusted = false; + + const response = await request(createApp(runtime)).get( + '/workspace/runtime/status', + ); + + expect(response.status).toBe(403); + }); + + it('uses the ordinary mutation gate for ensure', async () => { + const response = await request( + createApp(createRuntime(), { denyStrictMutations: true }), + ).post('/workspace/runtime/ensure'); + + expect(response.status).toBe(200); + }); + + it('maps initialization failures to a retryable 503 response', async () => { + const runtime = createRuntime(); + vi.mocked(runtime.bridge.preheat).mockRejectedValue( + new Error('child failed'), + ); + + const response = await request(createApp(runtime)).post( + '/workspace/runtime/ensure', + ); + + expect(response.status).toBe(503); + expect(response.headers['retry-after']).toBe('5'); + expect(response.body).toEqual({ + error: 'Workspace runtime failed to initialize', + code: 'runtime_initialization_failed', + }); + }); + + it.each([ + ['GET', '/workspace/runtime/status'], + ['POST', '/workspace/runtime/ensure'], + ])('returns 501 when %s %s is unsupported', async (method, path) => { + const runtime = createRuntime(); + delete runtime.bridge.getWorkspaceRuntimeLifecycleSnapshot; + const agent = request(createApp(runtime)); + + const response = + method === 'GET' ? await agent.get(path) : await agent.post(path); + + expect(response.status).toBe(501); + expect(response.body.code).toBe('workspace_runtime_not_supported'); + }); + + it.each([ + ['GET', '/workspace/runtime/status'], + ['POST', '/workspace/runtime/ensure'], + ])( + 'returns a retryable 503 while the primary runtime is transitioning for %s %s', + async (method, path) => { + const runtime = createRuntime(); + const agent = request( + createApp(runtime, { runtimeState: 'transitioning' }), + ); + + const response = + method === 'GET' ? await agent.get(path) : await agent.post(path); + + expect(response.status).toBe(503); + expect(response.headers['retry-after']).toBe('1'); + expect(response.body).toMatchObject({ + code: 'workspace_runtime_unavailable', + workspaceCwd: runtime.workspaceCwd, + workspaceId: runtime.workspaceId, + }); + expect(runtime.bridge.preheat).not.toHaveBeenCalled(); + }, + ); + + it('resolves a qualified runtime without falling back to primary', async () => { + const primary = createRuntime('/primary'); + const secondary = createRuntime('/secondary'); + const registry = { + primaryEntry: { + state: 'active', + workspaceId: primary.workspaceId, + workspaceCwd: primary.workspaceCwd, + current: { runtime: primary }, + }, + getEntryByWorkspaceId: vi.fn((workspaceId: string) => + workspaceId === secondary.workspaceId + ? { + state: 'active', + workspaceId: secondary.workspaceId, + workspaceCwd: secondary.workspaceCwd, + current: { runtime: secondary }, + } + : undefined, + ), + } as unknown as WorkspaceRegistry; + const app = express(); + app.use(express.json()); + registerWorkspaceQualifiedRuntimeRoutes(app, { + workspaceRegistry: registry, + mutate: () => (_req, _res, next) => next(), + safeBody: (req) => (req.body ?? {}) as Record, + sendBridgeError, + }); + + const selected = await request(app).get( + `/workspaces/${encodeURIComponent(secondary.workspaceId)}/runtime/status`, + ); + const missing = await request(app).get( + '/workspaces/missing/runtime/status', + ); + + expect(selected.status).toBe(200); + expect(selected.body.workspaceCwd).toBe('/secondary'); + expect(missing.status).toBe(400); + expect(primary.bridge.preheat).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/serve/routes/workspace-runtime.ts b/packages/cli/src/serve/routes/workspace-runtime.ts new file mode 100644 index 00000000000..3e76e2f4786 --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-runtime.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application, Request, RequestHandler, Response } from 'express'; +import type { SendBridgeError } from '../server/error-response.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, + sendWorkspaceRuntimeUnavailable, +} from '../workspace-route-runtime.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; +import { getWorkspaceRuntimeCoordinatorIfSupported } from '../workspace-runtime-coordinator.js'; + +interface RegisterWorkspaceRuntimeRoutesDeps { + workspaceRegistry: WorkspaceRegistry; + mutate: (opts?: { strict?: boolean }) => RequestHandler; + safeBody: (req: Request) => Record; + sendBridgeError: SendBridgeError; +} + +type ResolveRuntime = (req: Request, res: Response) => WorkspaceRuntime | null; + +function requireRuntimeCoordinator(runtime: WorkspaceRuntime, res: Response) { + const coordinator = getWorkspaceRuntimeCoordinatorIfSupported(runtime); + if (coordinator) return coordinator; + res.status(501).json({ + error: 'Workspace runtime lifecycle is not supported', + code: 'workspace_runtime_not_supported', + }); + return null; +} + +function registerFor( + app: Application, + base: string, + resolveRuntime: ResolveRuntime, + deps: Pick< + RegisterWorkspaceRuntimeRoutesDeps, + 'mutate' | 'safeBody' | 'sendBridgeError' + >, +): void { + const { mutate, safeBody, sendBridgeError } = deps; + + app.post(`${base}/runtime/ensure`, mutate(), async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const coordinator = requireRuntimeCoordinator(runtime, res); + if (!coordinator) return; + const route = `POST ${base}/runtime/ensure`; + if (Object.keys(safeBody(req)).length > 0) { + res.status(400).json({ + error: 'Workspace runtime ensure does not accept parameters', + code: 'workspace_runtime_ensure_takes_no_parameters', + }); + return; + } + try { + res.status(200).json(await coordinator.ensure()); + } catch (error) { + sendBridgeError(res, error, { route }); + } + }); + + app.get(`${base}/runtime/status`, (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const coordinator = requireRuntimeCoordinator(runtime, res); + if (!coordinator) return; + res.status(200).json(coordinator.status()); + }); +} + +export function registerWorkspaceRuntimeRoutes( + app: Application, + deps: RegisterWorkspaceRuntimeRoutesDeps, +): void { + registerFor( + app, + '/workspace', + (_req, res) => { + const entry = deps.workspaceRegistry.primaryEntry; + const runtime = + entry.state === 'active' ? entry.current?.runtime : undefined; + if (!runtime) { + sendWorkspaceRuntimeUnavailable(res, entry); + return null; + } + return requireTrustedWorkspaceRuntime(runtime, res) ? runtime : null; + }, + deps, + ); +} + +export function registerWorkspaceQualifiedRuntimeRoutes( + app: Application, + deps: Pick< + RegisterWorkspaceRuntimeRoutesDeps, + 'mutate' | 'safeBody' | 'sendBridgeError' + > & { workspaceRegistry: WorkspaceRegistry }, +): void { + registerFor( + app, + '/workspaces/:workspace', + (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + if (!runtime) return null; + return requireTrustedWorkspaceRuntime(runtime, res) ? runtime : null; + }, + deps, + ); +} diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 8616d3ae93a..b9162ce3246 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -83,6 +83,7 @@ import { workspaceRegistrationId, type WorkspaceRegistrationStore, } from './workspace-registration-store.js'; +import type { WorkspaceRegistry } from './workspace-registry.js'; import { getDeferredRuntimeRequestTiming } from './server/request-helpers.js'; import type { WorkspaceFileSystemFactory } from './fs/workspace-file-system.js'; import { ConversationWorkspace } from './conversations/conversation-workspace.js'; @@ -504,6 +505,18 @@ function makeRuntimeBridge(): HttpAcpBridge { } as unknown as HttpAcpBridge; } +function makeLifecycleRuntimeBridge(): HttpAcpBridge { + return { + ...makeRuntimeBridge(), + getWorkspaceRuntimeLifecycleSnapshot: vi.fn().mockReturnValue({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + activeWork: false, + }), + } as unknown as HttpAcpBridge; +} + it('restores the Conversations runtime for a persisted scheduled task', async () => { delete process.env['QWEN_RUNTIME_DIR']; const tempRoot = fs.realpathSync( @@ -4124,6 +4137,16 @@ describe('runQwenServe telemetry validation', () => { await closing; } expect(createBridge).toHaveBeenCalledTimes(2); + const primaryEpochSource = createBridge.mock.calls.find( + ([options]) => options.boundWorkspace === canonicalizeWorkspace(primary), + )?.[0].runtimeEpochSource; + const secondaryEpochSource = createBridge.mock.calls.find( + ([options]) => + options.boundWorkspace === canonicalizeWorkspace(secondary), + )?.[0].runtimeEpochSource; + expect(primaryEpochSource).toBeDefined(); + expect(secondaryEpochSource).toBeDefined(); + expect(primaryEpochSource).not.toBe(secondaryEpochSource); for (const [options] of createBridge.mock.calls) { expect(options).toMatchObject({ delegateReadTextFileToClient: false, @@ -4137,6 +4160,77 @@ describe('runQwenServe telemetry validation', () => { } }); + it('drains every lifecycle runtime before close yields', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-runtime-drain-')), + ); + const primary = path.join(tmpDir, 'primary'); + const secondary = path.join(tmpDir, 'secondary'); + fs.mkdirSync(primary); + fs.mkdirSync(secondary); + vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({ + enabled: false, + sensitiveSpanAttributeMaxLength: 1024 * 1024, + }); + const primaryBridge = makeLifecycleRuntimeBridge(); + const secondaryBridge = makeLifecycleRuntimeBridge(); + vi.spyOn(acpBridge, 'createAcpSessionBridge') + .mockReturnValueOnce( + primaryBridge as ReturnType, + ) + .mockReturnValueOnce( + secondaryBridge as ReturnType, + ); + let workspaceRegistry: WorkspaceRegistry | undefined; + const originalCreateServeApp = serverModule.createServeApp; + vi.spyOn(serverModule, 'createServeApp').mockImplementation((...args) => { + workspaceRegistry = args[2]?.workspaceRegistry; + return originalCreateServeApp(...args); + }); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: [primary, secondary], + maxSessions: 1, + serveWebShell: false, + }, + { + preheatBridge: false, + daemonLogBaseDir: path.join(tmpDir, 'debug'), + }, + ); + let closing: Promise | undefined; + try { + expect(workspaceRegistry?.list()).toHaveLength(2); + + closing = handle.close(); + + const runtimes = workspaceRegistry!.list(); + expect(runtimes.map((runtime) => runtime.runtimeCoordinator)).toEqual([ + expect.anything(), + expect.anything(), + ]); + const ensureAttempts = runtimes.map((runtime) => + runtime.runtimeCoordinator!.ensure(), + ); + expect(primaryBridge.preheat).not.toHaveBeenCalled(); + expect(secondaryBridge.preheat).not.toHaveBeenCalled(); + await Promise.all( + ensureAttempts.map((attempt) => + expect(attempt).rejects.toMatchObject({ + code: 'workspace_draining', + }), + ), + ); + await closing; + } finally { + await (closing ?? handle.close()); + } + }); + it('keeps external built-in writes disabled for an injected primary filesystem factory', async () => { tmpDir = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'qws-injected-fs-')), @@ -4718,6 +4812,7 @@ describe('runQwenServe telemetry validation', () => { maxSessions: 1, eventRingSize: 1234, compactedReplayMaxBytes: 1024, + channelIdleTimeoutMs: 60_000, sessionRestoreTimeoutMs: 90_000, serveWebShell: false, }, @@ -4739,6 +4834,7 @@ describe('runQwenServe telemetry validation', () => { }); } expect(createBridge.mock.calls[0]?.[0]).toMatchObject({ + channelIdleTimeoutMs: 60_000, compactedReplayMaxBytes: 1024, eventRingSize: 1234, sessionRestoreTimeoutMs: 90_000, @@ -4746,6 +4842,7 @@ describe('runQwenServe telemetry validation', () => { onChannelDelivery: expect.any(Function), }); expect(createBridge.mock.calls[1]?.[0]).toMatchObject({ + channelIdleTimeoutMs: 60_000, compactedReplayMaxBytes: 1024, eventRingSize: 1234, sessionRestoreTimeoutMs: 90_000, @@ -4781,6 +4878,35 @@ describe('runQwenServe telemetry validation', () => { } }); + it('accepts an explicit zero channel idle timeout', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-idle-timeout-')), + ); + vi.spyOn(qwenCore, 'resolveTelemetrySettings').mockResolvedValue({ + enabled: false, + sensitiveSpanAttributeMaxLength: 1024 * 1024, + }); + + const createBridge = vi.spyOn(acpBridge, 'createAcpSessionBridge'); + const handle = await runQwenServe({ + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + channelIdleTimeoutMs: 0, + serveWebShell: false, + }); + try { + await handle.runtimeReady; + expect(createBridge).toHaveBeenCalled(); + expect(createBridge.mock.calls[0]?.[0]).toMatchObject({ + channelIdleTimeoutMs: 0, + }); + } finally { + await handle.close(); + } + }); + it('does not validate policy settings for untrusted secondary workspaces', async () => { tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'qws-ws-'))); const primary = path.join(tmpDir, 'primary'); @@ -6745,7 +6871,8 @@ describe('runQwenServe runtime startup failures', () => { const loadSettings = vi.spyOn(settingsRuntime, 'loadSettings'); const bootBridge = makeRuntimeBridge(); const reconciledBridge = makeRuntimeBridge(); - vi.spyOn(acpBridge, 'createAcpSessionBridge') + const createBridge = vi + .spyOn(acpBridge, 'createAcpSessionBridge') .mockReturnValueOnce( bootBridge as ReturnType, ) @@ -6778,6 +6905,14 @@ describe('runQwenServe runtime startup failures', () => { tmpDir, expect.objectContaining({ workspaceTrusted: true }), ); + expect(createBridge).toHaveBeenCalledTimes(2); + expect( + createBridge.mock.calls.map(([options]) => options.boundWorkspace), + ).toEqual([canonicalizeWorkspace(tmpDir), canonicalizeWorkspace(tmpDir)]); + expect(createBridge.mock.calls[0]?.[0].runtimeEpochSource).toBeDefined(); + expect(createBridge.mock.calls[1]?.[0].runtimeEpochSource).toBe( + createBridge.mock.calls[0]?.[0].runtimeEpochSource, + ); } finally { await handle.close(); } @@ -10731,6 +10866,7 @@ describe('runQwenServe runtime startup failures', () => { 'workspace_acp_status', 'persistent_workspace_registration', 'workspace_runtime_removal', + 'workspace_runtime', ]), modelServices: [], workspaceCwd: boundWorkspace, @@ -14143,6 +14279,14 @@ describe('runQwenServe channel worker supervisor', () => { fs.mkdtempSync(path.join(os.tmpdir(), 'qws-channel-worker-stuck-')), ); const bridge = makeFakeBridge(); + Object.assign(bridge, { + getWorkspaceRuntimeLifecycleSnapshot: vi.fn().mockReturnValue({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + activeWork: false, + }), + }); const worker = makeWorker({ enabled: true, state: 'failed', @@ -14162,6 +14306,12 @@ describe('runQwenServe channel worker supervisor', () => { const existingSigintListeners = new Set(process.rawListeners('SIGINT')); const existingSigtermListeners = new Set(process.rawListeners('SIGTERM')); const existingSighupListeners = new Set(process.rawListeners('SIGHUP')); + let workspaceRegistry: WorkspaceRegistry | undefined; + const originalCreateServeApp = serverModule.createServeApp; + vi.spyOn(serverModule, 'createServeApp').mockImplementation((...args) => { + workspaceRegistry = args[2]?.workspaceRegistry; + return originalCreateServeApp(...args); + }); await runQwenServe( { @@ -14179,6 +14329,10 @@ describe('runQwenServe channel worker supervisor', () => { daemonLogBaseDir: path.join(tmpDir, 'debug'), }, ); + const processRegistry = mockCreateSpawnChannelFactoryOptions.at(-1)?.[ + 'processRegistry' + ] as { shutdown: () => Promise }; + const processRegistryShutdown = vi.spyOn(processRegistry, 'shutdown'); const signalListener = process .rawListeners('SIGTERM') @@ -14194,11 +14348,20 @@ describe('runQwenServe channel worker supervisor', () => { expect(exitSpy).not.toHaveBeenCalled(); expect(worker.killAllSync).not.toHaveBeenCalled(); expect(pidfile.removeServeServiceInfo).not.toHaveBeenCalled(); + expect(processRegistryShutdown).toHaveBeenCalledOnce(); const logPath = path.join(tmpDir, 'debug', 'daemon', 'daemon.log'); expect(fs.readFileSync(logPath, 'utf8')).not.toContain('daemon stopped'); + const runtimeCoordinator = workspaceRegistry?.primary.runtimeCoordinator; + expect(runtimeCoordinator).toBeDefined(); + vi.mocked(bridge.preheat).mockClear(); + await expect(runtimeCoordinator!.ensure()).rejects.toMatchObject({ + code: 'workspace_draining', + }); + expect(bridge.preheat).not.toHaveBeenCalled(); await signalListener!('SIGTERM'); expect(worker.stop).toHaveBeenCalledTimes(2); + expect(processRegistryShutdown).toHaveBeenCalledTimes(2); expect(worker.killAllSync).not.toHaveBeenCalled(); expect(bridge.killAllSync).not.toHaveBeenCalled(); expect(pidfile.removeServeServiceInfo).toHaveBeenCalledWith(process.pid); @@ -14356,9 +14519,11 @@ describe('runQwenServe channel worker supervisor', () => { expect(signalListener).toBeDefined(); await signalListener!('SIGTERM'); expect(exitSpy).not.toHaveBeenCalled(); + expect(processRegistry.shutdown).toHaveBeenCalledOnce(); await signalListener!('SIGTERM'); expect(worker.stop).toHaveBeenCalledTimes(2); + expect(processRegistry.shutdown).toHaveBeenCalledTimes(2); expect(exitSpy).toHaveBeenCalledWith(1); } finally { for (const listener of process.rawListeners('SIGINT')) { @@ -16021,6 +16186,67 @@ describe('runQwenServe startup observability', () => { } }); + it('preheats the primary workspace runtime by default in production', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-startup-default-preheat-')), + ); + const bridge = installInternalBridge(() => Promise.resolve()); + const workerId = process.env['VITEST_WORKER_ID']; + delete process.env['VITEST_WORKER_ID']; + + let handle: RunHandle | undefined; + try { + handle = await runQwenServe({ + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }); + expect(await waitForPreheatStatus(handle, 'succeeded')).toMatchObject({ + status: 'succeeded', + }); + expect(bridge.preheat).toHaveBeenCalledOnce(); + } finally { + await handle?.close(); + if (workerId === undefined) { + delete process.env['VITEST_WORKER_ID']; + } else { + process.env['VITEST_WORKER_ID'] = workerId; + } + } + }); + + it('does not preheat an untrusted primary workspace', async () => { + tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'qws-startup-untrusted-')), + ); + const bridge = installInternalBridge(() => Promise.resolve()); + + const handle = await runQwenServe( + { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: tmpDir, + maxSessions: 1, + serveWebShell: false, + }, + { preheatBridge: true, trustedWorkspace: false }, + ); + + try { + await handle.runtimeReady; + expect(bridge.preheat).not.toHaveBeenCalled(); + expect(await readStartup(handle)).toMatchObject({ + preheat: { status: 'not_scheduled' }, + }); + } finally { + await handle.close(); + } + }); + it('tracks preheat failed state and error message for an internally-created bridge', async () => { tmpDir = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'qws-startup-preheat-')), diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 33458a75f4e..c688d4bd45e 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -2233,6 +2233,7 @@ async function loadServeRuntimeModules() { workspaceSkillsStatusModule, totalSessionAdmissionModule, workspaceRegistryModule, + workspaceRuntimeCoordinatorModule, promptLedgerModule, ] = await Promise.all([ import('./server.js'), @@ -2246,6 +2247,7 @@ async function loadServeRuntimeModules() { import('./workspace-skills-status.js'), import('./total-session-admission.js'), import('./workspace-registry.js'), + import('./workspace-runtime-coordinator.js'), import('./prompt-terminal-ledger.js'), ]); return { @@ -2274,6 +2276,8 @@ async function loadServeRuntimeModules() { workspaceRegistryModule.createWorkspaceSessionOwnerIndex, createWorkspaceGenerationGuard: workspaceRegistryModule.createWorkspaceGenerationGuard, + getWorkspaceRuntimeCoordinatorIfSupported: + workspaceRuntimeCoordinatorModule.getWorkspaceRuntimeCoordinatorIfSupported, createPromptLedgerSink: promptLedgerModule.createPromptLedgerSink, }; } @@ -2308,6 +2312,7 @@ function currentServeFeaturesForRunQwenServe( opts: ServeOptions, sessionShellCommandEnabled: boolean, sessionArtifactsPersistenceAvailable: boolean, + workspaceRuntimeAvailable: boolean, currentSessionSchedulingAvailable: boolean, env: Readonly>, nativeDirectoryPickerAvailable: boolean, @@ -2341,6 +2346,7 @@ function currentServeFeaturesForRunQwenServe( // path (serve-features.ts) so the bootstrap `/capabilities` window doesn't // briefly under-report them. nativeDirectoryPickerAvailable, + workspaceRuntimeAvailable, clientMcpOverWsEnabled: opts.clientMcpOverWs === true, cdpTunnelOverWsEnabled: opts.cdpTunnelOverWs === true, browserAutomationMcpAvailable: isBrowserAutomationMcpAvailable(opts, env), @@ -2353,6 +2359,7 @@ function createBootstrapCapabilities(input: { qwenCodeVersion?: string; sessionShellCommandEnabled: boolean; sessionArtifactsPersistenceAvailable: boolean; + workspaceRuntimeAvailable: boolean; currentSessionSchedulingAvailable: boolean; permissionPolicy: PermissionPolicy | undefined; env: Readonly>; @@ -2369,6 +2376,7 @@ function createBootstrapCapabilities(input: { input.opts, input.sessionShellCommandEnabled, input.sessionArtifactsPersistenceAvailable, + input.workspaceRuntimeAvailable, input.currentSessionSchedulingAvailable, input.env, input.nativeDirectoryPickerAvailable, @@ -2514,6 +2522,7 @@ function createBootstrapServeApp(input: { qwenCodeVersion?: string; sessionShellCommandEnabled: boolean; sessionArtifactsPersistenceAvailable: boolean; + workspaceRuntimeAvailable: boolean; currentSessionSchedulingAvailable: boolean; permissionPolicy: PermissionPolicy | undefined; multiWorkspaceCapabilitiesRequireRuntime: boolean; @@ -2533,6 +2542,7 @@ function createBootstrapServeApp(input: { qwenCodeVersion, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + workspaceRuntimeAvailable, currentSessionSchedulingAvailable, permissionPolicy, multiWorkspaceCapabilitiesRequireRuntime, @@ -2604,6 +2614,7 @@ function createBootstrapServeApp(input: { qwenCodeVersion, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + workspaceRuntimeAvailable, currentSessionSchedulingAvailable, permissionPolicy, env: process.env, @@ -2745,6 +2756,7 @@ function createBootstrapServeApp(input: { opts, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + workspaceRuntimeAvailable, currentSessionSchedulingAvailable, process.env, nativeDirectoryPickerAvailable, @@ -3183,7 +3195,7 @@ async function runQwenServeImpl( // copy before freezing runtime environments or starting auxiliary workers. delete process.env[EXTERNAL_TOOL_GUARD_TOKEN_ENV]; const channelDeliveryAuthorizations = new ChannelDeliveryAuthorizationStore(); - const shouldPreheat = !deps.bridge && shouldPreheatBridge(deps); + let shouldPreheat = !deps.bridge && shouldPreheatBridge(deps); const startup: DaemonStartupSnapshot = { processStartedAt: new Date( Date.now() - Math.round(process.uptime() * 1000), @@ -4229,6 +4241,9 @@ async function runQwenServeImpl( }; let runtimeApp: Application | undefined; let runtimeAppForCleanup: Application | undefined; + let getWorkspaceRuntimeCoordinatorIfSupported: + | (typeof import('./workspace-runtime-coordinator.js'))['getWorkspaceRuntimeCoordinatorIfSupported'] + | undefined; let bridgeRef: AcpSessionBridge | undefined = deps.bridge; let managedProcessRegistry: | { @@ -4617,6 +4632,8 @@ async function runQwenServeImpl( cliVersionPromise, import('../config/daemon-trust-policy.js'), ]); + getWorkspaceRuntimeCoordinatorIfSupported = + runtime.getWorkspaceRuntimeCoordinatorIfSupported; cliVersion = resolvedCliVersion; settingsRuntime.environment.preResolveHomeEnvOverrides(); const bootTrustSnapshot = await trustPolicy.readDaemonTrustPolicySnapshot(); @@ -4627,6 +4644,10 @@ async function runQwenServeImpl( ); const trustedWorkspace = deps.trustedWorkspace ?? bootPrimaryTrustDecision.targetTrusted; + if (shouldPreheat && !trustedWorkspace) { + shouldPreheat = false; + startup.preheat.status = 'not_scheduled'; + } const workspaceTrustHotReloadAvailable = deps.trustedWorkspace === undefined && deps.bridge === undefined && @@ -5060,6 +5081,26 @@ async function runQwenServeImpl( // `mcp_register`). Inert unless `opts.clientMcpOverWs` is on. const clientMcpSenderRegistry = new ClientMcpSenderRegistry(); const runtimeBridges: AcpSessionBridge[] = []; + // Epoch sources are deliberately never evicted: a removed workspace + // that is re-registered under the same cwd must continue from its + // last epoch, otherwise clients holding the old epoch would observe + // a regression. + const runtimeEpochSources = new Map< + string, + { current(): number; allocate(): number } + >(); + const runtimeEpochSourceFor = (workspaceCwd: string) => { + let source = runtimeEpochSources.get(workspaceCwd); + if (!source) { + let epoch = 0; + source = { + current: () => epoch, + allocate: () => ++epoch, + }; + runtimeEpochSources.set(workspaceCwd, source); + } + return source; + }; const totalSessionAdmission = runtime.createTotalSessionAdmissionController( { maxTotalSessions: opts.maxTotalSessions, @@ -5455,6 +5496,7 @@ async function runQwenServeImpl( ? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs } : {}), boundWorkspace, + runtimeEpochSource: runtimeEpochSourceFor(boundWorkspace), // Prompt terminal ledger: persisted beside the transcript so a // restarted daemon can reconcile dangling prompts on cold load. promptLedger: runtime.createPromptLedgerSink( @@ -6009,6 +6051,7 @@ async function runQwenServeImpl( ? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs } : {}), boundWorkspace: workspaceInput.cwd, + runtimeEpochSource: runtimeEpochSourceFor(workspaceInput.cwd), promptLedger: runtime.createPromptLedgerSink( workspaceInput.cwd, secondaryEnv.sessionRuntimeBaseDir, @@ -6672,6 +6715,7 @@ async function runQwenServeImpl( ? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs } : {}), boundWorkspace: cwd, + runtimeEpochSource: runtimeEpochSourceFor(cwd), // Live-conversation workspaces keep transcripts outside the // runtime storage layout, so no ledger sink is wired there. ...(provenance === 'live-conversation' @@ -7101,6 +7145,9 @@ async function runQwenServeImpl( const existing = runtimeCleanupPromises.get(runtimeToDrain); if (existing) return existing; const cleanup = (async () => { + runtime + .getWorkspaceRuntimeCoordinatorIfSupported(runtimeToDrain) + ?.dispose(); const containmentErrors: Error[] = []; try { await workspaceVoiceCoordinator.disposeRuntime( @@ -7743,6 +7790,9 @@ async function runQwenServeImpl( qwenCodeVersion: cliVersion, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + workspaceRuntimeAvailable: + deps.bridge === undefined || + typeof deps.bridge.getWorkspaceRuntimeLifecycleSnapshot === 'function', currentSessionSchedulingAvailable: false, permissionPolicy, multiWorkspaceCapabilitiesRequireRuntime: workspaceInputs.length > 1, @@ -8935,6 +8985,19 @@ async function runQwenServeImpl( cancelLiveDiscoveryRetry(); channelControlDraining = true; const initiallyMountedApp = runtimeApp ?? runtimeAppForCleanup; + const beginRuntimeCoordinatorDrains = ( + app: typeof initiallyMountedApp, + ): void => { + const registry = app?.locals?.['workspaceRegistry'] as + | WorkspaceRegistry + | undefined; + for (const workspaceRuntime of registry?.list() ?? []) { + getWorkspaceRuntimeCoordinatorIfSupported?.( + workspaceRuntime, + )?.beginDrain(); + } + }; + beginRuntimeCoordinatorDrains(initiallyMountedApp); const initiallyMountedManagement = initiallyMountedApp?.locals?.[ 'workspaceManagementHandle' ] as { sealAndWait?: () => Promise } | undefined; @@ -9148,6 +9211,7 @@ async function runQwenServeImpl( err instanceof Error ? err : null, ); }); + beginRuntimeCoordinatorDrains(appForCleanup); startProcessRegistryShutdown(); disposeRuntimeAppResources(appForCleanup); disposeDaemonEventLoopMonitor(); diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index a81201cfd34..a0b4fb2e5aa 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -789,6 +789,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'scratch_workspace_registration', 'workspace_runtime_removal', 'native_directory_picker', + 'workspace_runtime', 'workspace_qualified_rest_core', 'workspace_qualified_voice', 'workspace_qualified_memory', @@ -3277,6 +3278,20 @@ describe('createServeApp', () => { ); continue; } + if (feature === 'workspace_runtime') { + expect(predicate({ workspaceRuntimeAvailable: true })).toBe(true); + expect(predicate({ workspaceRuntimeAvailable: false })).toBe(false); + expect(predicate({})).toBe(false); + expect( + getAdvertisedServeFeatures(undefined, { + workspaceRuntimeAvailable: true, + }), + ).toContain(feature); + expect(getAdvertisedServeFeatures(undefined, {})).not.toContain( + feature, + ); + continue; + } if (feature === 'workspace_trust_hot_reload') { expect(predicate({ workspaceTrustHotReloadAvailable: true })).toBe( true, @@ -4215,6 +4230,7 @@ describe('createServeApp', () => { sessionArtifactsPersistenceAvailable: true, sessionGenerationAvailable: true, workspaceGenerationAvailable: true, + workspaceRuntimeAvailable: true, acpHttpEnabled: true, // Mirror the server.ts probe so the expectation matches on both // GUI and headless hosts. diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 69f654d11cf..742bc29564f 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -153,6 +153,10 @@ import { registerWorkspaceQualifiedStatusRoutes, registerWorkspaceStatusRoutes, } from './routes/workspace-status.js'; +import { + registerWorkspaceQualifiedRuntimeRoutes, + registerWorkspaceRuntimeRoutes, +} from './routes/workspace-runtime.js'; import { createDaemonWorkspaceService, type DaemonWorkspaceService, @@ -223,6 +227,7 @@ import { type WorkspaceRuntime, type WorkspaceRuntimeEnvMetadata, } from './workspace-registry.js'; +import { supportsWorkspaceRuntimeLifecycle } from './workspace-runtime-coordinator.js'; import { isInternalWorkspaceRuntime } from './workspace-runtime-visibility.js'; import { createWorkspaceRuntimeSessionService, @@ -1042,6 +1047,15 @@ export function createServeApp( nativeDirectoryPickerAvailable: deps.nativeDirectoryPickerAvailable ?? isNativeDirectoryPickerAvailable(), + workspaceRuntimeAvailable: () => { + const runtimes = workspaceRegistry.list(); + return ( + runtimes.length > 0 && + runtimes.every((runtime) => + supportsWorkspaceRuntimeLifecycle(runtime.bridge), + ) + ); + }, workspaceTrustHotReloadAvailable: deps.workspaceTrustHotReloadAvailable === true, isPrimaryWorkspaceTrusted: () => isPrimaryWorkspaceTrusted(), @@ -2255,6 +2269,18 @@ export function createServeApp( workspaceRegistry, sendBridgeError, }); + registerWorkspaceRuntimeRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + sendBridgeError, + }); + registerWorkspaceQualifiedRuntimeRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + sendBridgeError, + }); registerWorkspaceGitRoutes(app, { boundWorkspace: primaryBoundWorkspace, bridge: primaryBridge, diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index 890e1f4f69d..0d5475b58bb 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -14,27 +14,31 @@ import { SessionWriterLostError, SessionWriterUnavailableError, } from '@qwen-code/qwen-code-core'; +import type { DaemonLogger } from '../daemon-logger.js'; +import { + WorkspaceRuntimeInitializationError, + WorkspaceRuntimeStillStartingError, +} from '../workspace-runtime-coordinator.js'; import { sendBridgeError } from './error-response.js'; import { DaemonDrainingError } from './session-archive.js'; import { BridgeTimeoutError } from '../acp-session-bridge.js'; import { StandaloneSessionServiceError } from '../conversations/standalone-session-service.js'; import { ConversationRuntimeOwnershipError } from '../conversations/conversation-runtime-errors.js'; -import type { DaemonLogger } from '../daemon-logger.js'; function responseMock(): { response: Response; + set: ReturnType; status: ReturnType; json: ReturnType; - set: ReturnType; } { + const set = vi.fn(); const status = vi.fn(); const json = vi.fn(); - const set = vi.fn(); - const response = { status, json, set }; + const response = { set, status, json }; + set.mockReturnValue(response); status.mockReturnValue(response); json.mockReturnValue(response); - set.mockReturnValue(response); - return { response: response as unknown as Response, status, json, set }; + return { response: response as unknown as Response, set, status, json }; } describe('sendBridgeError session writer errors', () => { @@ -257,6 +261,59 @@ describe('sendBridgeError session writer errors', () => { }); }); + it('maps runtime still starting to 503 with Retry-After', () => { + const { response, set, status, json } = responseMock(); + const daemonLog = { + error: vi.fn(), + } as unknown as DaemonLogger; + + sendBridgeError( + response, + new WorkspaceRuntimeStillStartingError(), + { route: 'POST /workspace/runtime/ensure' }, + daemonLog, + ); + + expect(set).toHaveBeenCalledWith('Retry-After', '5'); + expect(status).toHaveBeenCalledWith(503); + expect(json).toHaveBeenCalledWith({ + error: 'Workspace runtime is still starting', + code: 'runtime_still_starting', + }); + expect(daemonLog.error).toHaveBeenCalledWith( + 'Workspace runtime is still starting', + expect.any(WorkspaceRuntimeStillStartingError), + { route: 'POST /workspace/runtime/ensure' }, + ); + }); + + it('logs the cause of runtime initialization failures', () => { + const { response, set, status, json } = responseMock(); + const cause = new Error('child initialize failed'); + const daemonLog = { + error: vi.fn(), + } as unknown as DaemonLogger; + + sendBridgeError( + response, + new WorkspaceRuntimeInitializationError(cause), + { route: 'POST /workspace/runtime/ensure' }, + daemonLog, + ); + + expect(daemonLog.error).toHaveBeenCalledWith( + 'child initialize failed', + cause, + { route: 'POST /workspace/runtime/ensure' }, + ); + expect(set).toHaveBeenCalledWith('Retry-After', '5'); + expect(status).toHaveBeenCalledWith(503); + expect(json).toHaveBeenCalledWith({ + error: 'Workspace runtime failed to initialize', + code: 'runtime_initialization_failed', + }); + }); + it('maps an untrusted workspace bridge error to 403', () => { const { response, status, json } = responseMock(); const error = Object.assign(new Error('Workspace is not trusted'), { diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index dd8611dc964..f5079d71337 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -58,6 +58,10 @@ import { import type { DaemonLogger } from '../daemon-logger.js'; import { mapWorkspaceSkillToggleError } from '../workspace-service/types.js'; import { sendGenerationClosedError } from '../workspace-route-runtime.js'; +import { + WorkspaceRuntimeInitializationError, + WorkspaceRuntimeStillStartingError, +} from '../workspace-runtime-coordinator.js'; import { DaemonDrainingError } from './session-archive.js'; import { StandaloneSessionServiceError } from '../conversations/standalone-session-service.js'; import { ConversationRuntimeOwnershipError } from '../conversations/conversation-runtime-errors.js'; @@ -74,6 +78,50 @@ export type SendBridgeError = ( ctx?: BridgeErrorContext, ) => void; +function reportBridgeError( + err: unknown, + ctx: BridgeErrorContext | undefined, + daemonLog: DaemonLogger | undefined, +): void { + recordDaemonBridgeError(err); + const extraContext = bridgeErrorExtraContext(ctx); + recordDaemonError(undefined, err, { + ...(ctx?.route ? { 'http.route': ctx.route } : {}), + ...(ctx?.sessionId ? { 'session.id': ctx.sessionId } : {}), + }); + emitDaemonLog('Daemon bridge error.', { + ...(ctx?.route ? { 'http.route': ctx.route } : {}), + ...(ctx?.sessionId ? { 'session.id': ctx.sessionId } : {}), + ...extraContext, + 'error.type': err instanceof Error ? err.name : typeof err, + 'error.message': (err instanceof Error ? err.message : String(err)).slice( + 0, + 1024, + ), + }); + if (daemonLog) { + daemonLog.error( + err instanceof Error ? err.message : String(err), + err instanceof Error ? err : undefined, + { + ...(ctx?.route ? { route: ctx.route } : {}), + ...(ctx?.sessionId ? { sessionId: ctx.sessionId } : {}), + ...extraContext, + }, + ); + return; + } + const ctxParts = [ + ctx?.route, + ctx?.sessionId ? `session=${ctx.sessionId}` : undefined, + ...Object.entries(extraContext).map(([key, value]) => `${key}=${value}`), + ].filter(Boolean); + const ctxStr = ctxParts.length > 0 ? ` (${ctxParts.join(' ')})` : ''; + writeStderrLine( + `qwen serve: bridge error${ctxStr}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, + ); +} + const SESSION_WRITER_ERROR_MESSAGES = { session_writer_conflict: 'This session is already open in another Qwen process.', @@ -284,6 +332,24 @@ export function sendBridgeError( return; } if (sendGenerationClosedError(res, err)) return; + if (err instanceof WorkspaceRuntimeStillStartingError) { + reportBridgeError(err, ctx, daemonLog); + res.set('Retry-After', '5'); + res.status(503).json({ + error: err.message, + code: 'runtime_still_starting', + }); + return; + } + if (err instanceof WorkspaceRuntimeInitializationError) { + reportBridgeError(err.cause ?? err, ctx, daemonLog); + res.set('Retry-After', '5'); + res.status(503).json({ + error: err.message, + code: 'runtime_initialization_failed', + }); + return; + } if ( err instanceof Error && 'code' in err && @@ -902,43 +968,7 @@ export function sendBridgeError( // structured daemon logger (which tees to stderr + log file). When // absent (tests, direct embeds), fall back to the legacy stderr-only // `writeStderrLine` path. - recordDaemonBridgeError(err); - const extraContext = bridgeErrorExtraContext(ctx); - recordDaemonError(undefined, err, { - ...(ctx?.route ? { 'http.route': ctx.route } : {}), - ...(ctx?.sessionId ? { 'session.id': ctx.sessionId } : {}), - }); - emitDaemonLog('Daemon bridge error.', { - ...(ctx?.route ? { 'http.route': ctx.route } : {}), - ...(ctx?.sessionId ? { 'session.id': ctx.sessionId } : {}), - ...extraContext, - 'error.type': err instanceof Error ? err.name : typeof err, - 'error.message': (err instanceof Error ? err.message : String(err)).slice( - 0, - 1024, - ), - }); - if (daemonLog) { - daemonLog.error( - err instanceof Error ? err.message : String(err), - err instanceof Error ? err : undefined, - { - ...(ctx?.route ? { route: ctx.route } : {}), - ...(ctx?.sessionId ? { sessionId: ctx.sessionId } : {}), - ...extraContext, - }, - ); - } else { - const ctxParts = [ - ctx?.route, - ctx?.sessionId ? `session=${ctx.sessionId}` : undefined, - ...Object.entries(extraContext).map(([key, value]) => `${key}=${value}`), - ].filter(Boolean); - const ctxStr = ctxParts.length > 0 ? ` (${ctxParts.join(' ')})` : ''; - writeStderrLine( - `qwen serve: bridge error${ctxStr}: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, - ); - } + reportBridgeError(err, ctx, daemonLog); res.status(500).json(errorPayload(err)); } diff --git a/packages/cli/src/serve/server/serve-features.ts b/packages/cli/src/serve/server/serve-features.ts index 5f049f0c5c3..2565621b2cd 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -60,6 +60,7 @@ interface CreateServeFeaturesDeps { acpHttpEnabled?: boolean; workspaceRuntimeRemovalAvailable?: boolean; nativeDirectoryPickerAvailable?: boolean; + workspaceRuntimeAvailable: () => boolean; workspaceTrustHotReloadAvailable?: boolean; isPrimaryWorkspaceTrusted?: () => boolean; env?: Readonly>; @@ -97,6 +98,7 @@ export function createServeFeatures( acpHttpEnabled, workspaceRuntimeRemovalAvailable, nativeDirectoryPickerAvailable, + workspaceRuntimeAvailable, workspaceTrustHotReloadAvailable, } = deps; const getEnv = deps.getEnv ?? (() => deps.env ?? process.env); @@ -151,6 +153,7 @@ export function createServeFeatures( scratchWorkspaceRegistrationAvailable(), workspaceRuntimeRemovalAvailable, nativeDirectoryPickerAvailable, + workspaceRuntimeAvailable: workspaceRuntimeAvailable(), workspaceTrustHotReloadAvailable, acpHttpEnabled: currentAcpHttpEnabled, realtimeVoiceEnabled: realtimeVoiceEnabled(), diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index de902671bb2..b311240ed23 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -321,7 +321,7 @@ export interface ServeOptions { * Per-SSE-connection idle deadline. */ writerIdleTimeoutMs?: number; - /** Non-negative ms to keep ACP child alive after last session closes. 0 = immediate kill (default). */ + /** ACP child auto-reap delay in ms. 0 or unset = immediate kill. */ channelIdleTimeoutMs?: number; /** Session reaper scan interval in ms. 0 = disabled. Default: 60000. */ sessionReapIntervalMs?: number; diff --git a/packages/cli/src/serve/workspace-registry.ts b/packages/cli/src/serve/workspace-registry.ts index 92de6e20ef9..a4c005c46aa 100644 --- a/packages/cli/src/serve/workspace-registry.ts +++ b/packages/cli/src/serve/workspace-registry.ts @@ -12,6 +12,7 @@ import type { ClientMcpSenderRegistry } from './acp-http/client-mcp-sender-regis import type { WorkspaceFileSystemFactory } from './fs/index.js'; import type { WorkspaceRuntimeProvenance } from './managed-scratch-workspace.js'; import type { DaemonWorkspaceService } from './workspace-service/types.js'; +import type { WorkspaceRuntimeCoordinator } from './workspace-runtime-coordinator.js'; import { isInternalWorkspaceRuntime } from './workspace-runtime-visibility.js'; export interface WorkspaceRuntimeEnvMetadata { @@ -48,6 +49,7 @@ export interface WorkspaceRuntime { readonly clientMcpSenderRegistry: ClientMcpSenderRegistry; readonly generationGuard?: WorkspaceGenerationGuard; readonly trustMaterialization?: string; + runtimeCoordinator?: WorkspaceRuntimeCoordinator; } export type WorkspaceEntryState = diff --git a/packages/cli/src/serve/workspace-runtime-coordinator.test.ts b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts new file mode 100644 index 00000000000..c300ac319ba --- /dev/null +++ b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { + AcpSessionBridge, + BridgeWorkspaceRuntimeLifecycleSnapshot, +} from './acp-session-bridge.js'; +import type { WorkspaceRuntime } from './workspace-registry.js'; +import { + getWorkspaceRuntimeCoordinator, + getWorkspaceRuntimeCoordinatorIfSupported, + WorkspaceRuntimeCoordinator, + WorkspaceRuntimeInitializationError, + WorkspaceRuntimeStillStartingError, +} from './workspace-runtime-coordinator.js'; + +function makeRuntime() { + let snapshot: BridgeWorkspaceRuntimeLifecycleSnapshot = { + state: 'cold', + runtimeLive: false, + runtimeEpoch: 0, + activeWork: false, + }; + const preheat = vi.fn(async () => { + snapshot = { + state: 'idle', + runtimeLive: true, + runtimeEpoch: snapshot.runtimeEpoch + 1, + activeWork: false, + }; + }); + const bridge = { + sessionCount: 0, + preheat, + getWorkspaceRuntimeLifecycleSnapshot: () => snapshot, + } as unknown as AcpSessionBridge; + const runtime = { + workspaceCwd: '/workspace', + bridge, + } as unknown as WorkspaceRuntime; + return { + runtime, + bridge, + preheat, + setSnapshot( + update: Partial, + ): void { + snapshot = { ...snapshot, ...update }; + }, + }; +} + +describe('WorkspaceRuntimeCoordinator', () => { + it('starts one workspace runtime without creating a session', async () => { + const harness = makeRuntime(); + const coordinator = new WorkspaceRuntimeCoordinator( + harness.runtime, + harness.bridge as AcpSessionBridge & { + getWorkspaceRuntimeLifecycleSnapshot(): BridgeWorkspaceRuntimeLifecycleSnapshot; + }, + ); + + const result = await coordinator.ensure(); + + expect(result).toMatchObject({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + }); + expect(harness.preheat).toHaveBeenCalledWith({ + keepAliveMs: 600_000, + }); + }); + + it('renews the warm window on every ensure call', async () => { + const harness = makeRuntime(); + harness.setSnapshot({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + }); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + + await coordinator.ensure(); + await coordinator.ensure(); + + expect(harness.preheat).toHaveBeenCalledTimes(2); + expect(harness.preheat).toHaveBeenNthCalledWith(1, { + keepAliveMs: 600_000, + }); + expect(harness.preheat).toHaveBeenNthCalledWith(2, { + keepAliveMs: 600_000, + }); + }); + + it('reports the bridge lifecycle snapshot without synthesizing state', () => { + const harness = makeRuntime(); + harness.setSnapshot({ + state: 'stopping', + runtimeLive: false, + runtimeEpoch: 4, + activeWork: true, + }); + + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + + expect(coordinator.status()).toMatchObject({ + state: 'stopping', + runtimeLive: false, + runtimeEpoch: 4, + }); + expect(coordinator.hasActiveWork()).toBe(true); + }); + + it('rejects new work while draining and resumes after rollback', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + + coordinator.beginDrain(); + await expect(coordinator.ensure()).rejects.toMatchObject({ + code: 'workspace_draining', + workspaceCwd: '/workspace', + }); + + coordinator.cancelDrain(); + await expect(coordinator.ensure()).resolves.toMatchObject({ + runtimeLive: true, + }); + }); + + it('times out one observer without cancelling the shared physical start', async () => { + vi.useFakeTimers(); + try { + const harness = makeRuntime(); + let release!: () => void; + const physicalStart = new Promise((resolve) => { + release = () => { + harness.setSnapshot({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + }); + resolve(); + }; + }); + harness.preheat.mockImplementation(() => physicalStart); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + + const first = coordinator.ensure(10); + void first.catch(() => undefined); + await vi.advanceTimersByTimeAsync(10); + await expect(first).rejects.toBeInstanceOf( + WorkspaceRuntimeStillStartingError, + ); + + const second = coordinator.ensure(10); + expect(harness.preheat).toHaveBeenCalledTimes(2); + release(); + await expect(second).resolves.toMatchObject({ + runtimeLive: true, + runtimeEpoch: 1, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('wraps a failed physical start as an initialization failure', async () => { + const harness = makeRuntime(); + harness.preheat.mockRejectedValue(new Error('child failed')); + + await expect( + getWorkspaceRuntimeCoordinator(harness.runtime).ensure(), + ).rejects.toBeInstanceOf(WorkspaceRuntimeInitializationError); + }); + + it('rejects when preheat resolves without a live runtime', async () => { + const harness = makeRuntime(); + harness.preheat.mockResolvedValue(undefined); + + await expect( + getWorkspaceRuntimeCoordinator(harness.runtime).ensure(), + ).rejects.toBeInstanceOf(WorkspaceRuntimeInitializationError); + }); + + it('stores one coordinator per supported runtime', () => { + const harness = makeRuntime(); + + expect(getWorkspaceRuntimeCoordinator(harness.runtime)).toBe( + getWorkspaceRuntimeCoordinator(harness.runtime), + ); + }); + + it('does not create a coordinator for an older injected bridge', () => { + const harness = makeRuntime(); + delete (harness.bridge as Partial) + .getWorkspaceRuntimeLifecycleSnapshot; + + expect(getWorkspaceRuntimeCoordinatorIfSupported(harness.runtime)).toBe( + undefined, + ); + }); +}); diff --git a/packages/cli/src/serve/workspace-runtime-coordinator.ts b/packages/cli/src/serve/workspace-runtime-coordinator.ts new file mode 100644 index 00000000000..4a4a1b1ce1a --- /dev/null +++ b/packages/cli/src/serve/workspace-runtime-coordinator.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + STATUS_SCHEMA_VERSION, + type ServeWorkspaceRuntimeStatus, +} from '@qwen-code/acp-bridge/status'; +import type { + AcpSessionBridge, + BridgeWorkspaceRuntimeLifecycleSnapshot, +} from './acp-session-bridge.js'; +import { WorkspaceDrainingError } from './acp-session-bridge.js'; +import type { WorkspaceRuntime } from './workspace-registry.js'; + +const DEFAULT_ENSURE_TIMEOUT_MS = 60_000; +const ENSURE_KEEP_ALIVE_MS = 10 * 60_000; + +type LifecycleAcpSessionBridge = AcpSessionBridge & { + getWorkspaceRuntimeLifecycleSnapshot(): BridgeWorkspaceRuntimeLifecycleSnapshot; +}; + +export class WorkspaceRuntimeStillStartingError extends Error { + constructor() { + super('Workspace runtime is still starting'); + this.name = 'WorkspaceRuntimeStillStartingError'; + } +} + +export class WorkspaceRuntimeInitializationError extends Error { + constructor(cause: unknown) { + super('Workspace runtime failed to initialize', { cause }); + this.name = 'WorkspaceRuntimeInitializationError'; + } +} + +function withTimeout(operation: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + return Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new WorkspaceRuntimeStillStartingError()), + timeoutMs, + ); + timer.unref?.(); + }), + ]).finally(() => { + if (timer) clearTimeout(timer); + }); +} + +export function supportsWorkspaceRuntimeLifecycle( + bridge: AcpSessionBridge, +): bridge is LifecycleAcpSessionBridge { + return typeof bridge.getWorkspaceRuntimeLifecycleSnapshot === 'function'; +} + +export class WorkspaceRuntimeCoordinator { + private disposed = false; + + private draining = false; + + constructor( + private readonly runtime: WorkspaceRuntime, + private readonly bridge: LifecycleAcpSessionBridge, + ) {} + + beginDrain(): void { + this.draining = true; + } + + cancelDrain(): void { + if (!this.disposed) this.draining = false; + } + + hasActiveWork(): boolean { + return this.bridge.getWorkspaceRuntimeLifecycleSnapshot().activeWork; + } + + dispose(): void { + this.disposed = true; + this.draining = true; + } + + status(): ServeWorkspaceRuntimeStatus { + const snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: this.runtime.workspaceCwd, + state: snapshot.state, + runtimeLive: snapshot.runtimeLive, + runtimeEpoch: snapshot.runtimeEpoch, + }; + } + + async ensure( + timeoutMs = DEFAULT_ENSURE_TIMEOUT_MS, + ): Promise { + this.assertAcceptingWork(); + try { + await withTimeout( + this.bridge.preheat({ keepAliveMs: ENSURE_KEEP_ALIVE_MS }), + timeoutMs, + ); + } catch (error) { + this.assertAcceptingWork(); + if (error instanceof WorkspaceRuntimeStillStartingError) throw error; + throw new WorkspaceRuntimeInitializationError(error); + } + this.assertAcceptingWork(); + const status = this.status(); + if (!status.runtimeLive) { + throw new WorkspaceRuntimeInitializationError( + new Error('ACP preheat completed without a live runtime'), + ); + } + return status; + } + + private assertAcceptingWork(): void { + this.runtime.generationGuard?.assertOpen(); + if (this.disposed || this.draining) { + throw new WorkspaceDrainingError(this.runtime.workspaceCwd); + } + } +} + +export function getWorkspaceRuntimeCoordinatorIfSupported( + runtime: WorkspaceRuntime, +): WorkspaceRuntimeCoordinator | undefined { + if (!supportsWorkspaceRuntimeLifecycle(runtime.bridge)) return undefined; + runtime.runtimeCoordinator ??= new WorkspaceRuntimeCoordinator( + runtime, + runtime.bridge, + ); + return runtime.runtimeCoordinator; +} + +export function getWorkspaceRuntimeCoordinator( + runtime: WorkspaceRuntime, +): WorkspaceRuntimeCoordinator { + const coordinator = getWorkspaceRuntimeCoordinatorIfSupported(runtime); + if (!coordinator) { + throw new Error('Workspace runtime lifecycle is not supported'); + } + return coordinator; +} diff --git a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts index 92da188bed9..cc515f05367 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -3087,7 +3087,7 @@ describe('createDaemonWorkspaceService', () => { }); }); - it('returns ready without preheating when the channel is already live', async () => { + it('renews the idle window when the channel is already live', async () => { const preheatAcpChild = vi.fn().mockResolvedValue(undefined); const svc = createDaemonWorkspaceService( makeDeps({ isChannelLive: () => true, preheatAcpChild }), @@ -3096,7 +3096,7 @@ describe('createDaemonWorkspaceService', () => { const result = await svc.preheatAcpChild(makeCtx()); expect(result).toMatchObject({ ready: true, channelLive: true }); - expect(preheatAcpChild).not.toHaveBeenCalled(); + expect(preheatAcpChild).toHaveBeenCalledOnce(); }); it('returns an error when ACP preheat is unavailable', async () => { @@ -3146,13 +3146,13 @@ describe('createDaemonWorkspaceService', () => { channelLive: false, reason: 'timeout', }); - expect(preheatAcpChild).toHaveBeenCalledOnce(); + expect(preheatAcpChild).toHaveBeenCalledTimes(2); expect(mockWriteStderrLine).toHaveBeenCalledWith( 'qwen serve: ACP preheat timed out after 1ms', ); }); - it('lets concurrent waiters share one preheat attempt', async () => { + it('forwards every concurrent observer to the bridge', async () => { const pending = deferred(); let live = false; const preheatAcpChild = vi.fn(() => pending.promise); @@ -3160,8 +3160,12 @@ describe('createDaemonWorkspaceService', () => { makeDeps({ isChannelLive: () => live, preheatAcpChild }), ); - const first = svc.preheatAcpChild(makeCtx(), { timeoutMs: 1000 }); - const second = svc.preheatAcpChild(makeCtx(), { timeoutMs: 1000 }); + const first = svc.preheatAcpChild(makeCtx(), { + timeoutMs: 1000, + }); + const second = svc.preheatAcpChild(makeCtx(), { + timeoutMs: 1000, + }); live = true; pending.resolve(); @@ -3169,10 +3173,12 @@ describe('createDaemonWorkspaceService', () => { expect.objectContaining({ ready: true, channelLive: true }), expect.objectContaining({ ready: true, channelLive: true }), ]); - expect(preheatAcpChild).toHaveBeenCalledOnce(); + expect(preheatAcpChild).toHaveBeenCalledTimes(2); + expect(preheatAcpChild).toHaveBeenNthCalledWith(1); + expect(preheatAcpChild).toHaveBeenNthCalledWith(2); }); - it('allows a new attempt after the shared preheat settles', async () => { + it('allows a new attempt after a timed-out observer', async () => { const firstAttempt = deferred(); let live = false; const preheatAcpChild = vi diff --git a/packages/cli/src/serve/workspace-service/index.ts b/packages/cli/src/serve/workspace-service/index.ts index 94c7749eb05..c8afdb5bd46 100644 --- a/packages/cli/src/serve/workspace-service/index.ts +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -281,7 +281,6 @@ export function createDaemonWorkspaceService( promise: Promise; } | undefined; - let inFlightAcpPreheat: Promise | undefined; const invalidateWorkspaceSkillsSnapshot = () => { workspaceSkillsGeneration += 1; @@ -425,9 +424,9 @@ export function createDaemonWorkspaceService( // SkillManager (including extension-provided skills). `queryWorkspaceStatus` // returns the idle placeholder (`initialized: false`, empty `skills`) // whenever no child channel is live — before the first session, after - // the child is reaped on session close (`--channel-idle-timeout-ms` - // defaults to an immediate kill), and when a cold-start preheat times - // out before the child ever answers. In those windows the Web Shell's + // the default immediate reap or a configured idle timeout stops it, and + // when a cold-start preheat times out before the child ever answers. In + // those windows the Web Shell's // pre-first-prompt slash-command list would otherwise drop every skill, // so `/rev` stops autocompleting `/review`. `initialized` cleanly // separates a real child answer (always `true`) from the placeholder. @@ -461,10 +460,10 @@ export function createDaemonWorkspaceService( durationMs: Math.max(0, Math.round(performance.now() - startedAt)), }); - if (channelLive()) { - return finish({ ready: true, channelLive: true }); - } if (!preheatAcpChildOnBridge) { + if (channelLive()) { + return finish({ ready: true, channelLive: true }); + } return finish({ ready: false, channelLive: false, @@ -473,41 +472,18 @@ export function createDaemonWorkspaceService( }); } - if (!inFlightAcpPreheat) { - const promise = Promise.resolve().then(preheatAcpChildOnBridge); - inFlightAcpPreheat = promise; - void promise.then( - () => { - if (inFlightAcpPreheat === promise) { - inFlightAcpPreheat = undefined; - } - }, - (err) => { - try { - writeStderrLineSafe( - `qwen serve: ACP preheat failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } finally { - if (inFlightAcpPreheat === promise) { - inFlightAcpPreheat = undefined; - } - } - }, - ); - } - - const sharedPreheat = inFlightAcpPreheat; + const preheat = Promise.resolve().then(() => preheatAcpChildOnBridge()); try { - await withTimeout( - sharedPreheat, - opts?.timeoutMs ?? 5_000, - 'ACP preheat', - ); + await withTimeout(preheat, opts?.timeoutMs ?? 5_000, 'ACP preheat'); } catch (err) { if (err instanceof TimeoutError) { writeStderrLineSafe( `qwen serve: ACP preheat timed out after ${opts?.timeoutMs ?? 5_000}ms`, ); + } else { + writeStderrLineSafe( + `qwen serve: ACP preheat failed: ${err instanceof Error ? err.message : String(err)}`, + ); } const live = channelLive(); if (live) { diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 1dc769a1d0c..eb3366b1c27 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -109,6 +109,7 @@ import type { DaemonWorkspaceProvidersStatus, DaemonWorkspaceAcpStatusResult, DaemonWorkspaceAcpPreheatResult, + DaemonWorkspaceRuntimeStatus, DaemonWorkspaceSkillsStatus, DaemonWorkspaceToolsStatus, DaemonWriteMemoryRequest, @@ -393,6 +394,13 @@ const SESSION_RESTORE_TIMEOUT_HEADROOM_MS = 10_000; const VOICE_TRANSCRIPTION_DEFAULT_TIMEOUT_MS = 65_000; const GITHUB_SETUP_DEFAULT_TIMEOUT_MS = 90_000; const CHANNEL_NOTIFY_DEFAULT_TIMEOUT_MS = 35_000; +// Keep in sync with DEFAULT_ENSURE_TIMEOUT_MS in +// packages/cli/src/serve/workspace-runtime-coordinator.ts. +const WORKSPACE_RUNTIME_ENSURE_SERVER_DEADLINE_MS = 60_000; +const WORKSPACE_RUNTIME_ENSURE_CLIENT_HEADROOM_MS = 2_000; +const WORKSPACE_RUNTIME_ENSURE_TIMEOUT_MS = + WORKSPACE_RUNTIME_ENSURE_SERVER_DEADLINE_MS + + WORKSPACE_RUNTIME_ENSURE_CLIENT_HEADROOM_MS; const MAX_TIMER_DELAY_MS = 2_147_483_647; // Keep in sync with acp-bridge bridge.ts and CLI serve/server.ts. const DEFAULT_MAX_PENDING_PROMPTS_PER_SESSION = 5; @@ -1435,6 +1443,26 @@ export class DaemonClient { ); } + async ensureWorkspaceRuntime(): Promise { + return await this.jsonRequest( + '/workspace/runtime/ensure', + 'POST /workspace/runtime/ensure', + { + method: 'POST', + timeoutMs: WORKSPACE_RUNTIME_ENSURE_TIMEOUT_MS, + mode: 'rest', + }, + ); + } + + async workspaceRuntimeStatus(): Promise { + return await this.jsonRequest( + '/workspace/runtime/status', + 'GET /workspace/runtime/status', + { mode: 'rest' }, + ); + } + async workspaceProviders(): Promise { return await this.fetchWithTimeout( `${this.baseUrl}/workspace/providers`, @@ -5741,6 +5769,28 @@ export class WorkspaceDaemonClient { return this.get('/mcp', 'GET /workspaces/:workspace/mcp'); } + ensureRuntime(): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/runtime/ensure', + 'POST /workspaces/:workspace/runtime/ensure', + { + method: 'POST', + timeoutMs: WORKSPACE_RUNTIME_ENSURE_TIMEOUT_MS, + mode: 'rest', + }, + ); + } + + runtimeStatus(): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/runtime/status', + 'GET /workspaces/:workspace/runtime/status', + { mode: 'rest' }, + ); + } + /** * Send text directly through this exact workspace's channel worker. * A successful capability pre-flight does not guarantee worker liveness; diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index c844f70547d..a9852d6b5cc 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -692,6 +692,7 @@ export type { DaemonWorkspacePreflightStatus, DaemonWorkspaceAcpStatusResult, DaemonWorkspaceAcpPreheatResult, + DaemonWorkspaceRuntimeStatus, DaemonWorkspaceProviderCurrent, DaemonWorkspaceProviderModel, DaemonWorkspaceProviderStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 5c4a1814ade..96877be0c86 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -129,6 +129,7 @@ export interface DaemonWorkspaceRemovalActivity { memoryTasks: number; channelWorkers: number; voiceSessions?: number; + workspaceRuntime?: number; } export interface DaemonWorkspaceRemovalResult { @@ -1911,6 +1912,14 @@ export interface DaemonWorkspaceAcpPreheatResult { error?: string; } +export interface DaemonWorkspaceRuntimeStatus { + v: 1; + workspaceCwd: string; + state: 'cold' | 'starting' | 'active' | 'idle' | 'stopping'; + runtimeLive: boolean; + runtimeEpoch: number; +} + export interface DaemonWorkspaceProviderCurrent { authType?: string; modelId?: string; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 83f30a10917..626dc80d68c 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -304,6 +304,7 @@ export { type DaemonWorkspaceMcpInitializeResult, type DaemonWorkspaceAcpStatusResult, type DaemonWorkspaceAcpPreheatResult, + type DaemonWorkspaceRuntimeStatus, type DaemonWorkspaceProviderCurrent, type DaemonWorkspaceProviderModel, type DaemonWorkspaceProviderStatus, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 81df66e260b..ac4d4a72f76 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1079,6 +1079,13 @@ describe('DaemonClient', () => { durationMs: 12, }; const acpStatus = { channelLive: true }; + const runtimeStatus = { + v: 1 as const, + workspaceCwd: '/work/a', + state: 'idle' as const, + runtimeLive: true, + runtimeEpoch: 1, + }; const { fetch, calls } = recordingFetch((req) => { if (req.url.endsWith('/workspace/mcp')) return jsonResponse(200, mcp); if (req.url.endsWith('/workspace/skills')) { @@ -1090,6 +1097,12 @@ describe('DaemonClient', () => { if (req.url.endsWith('/workspace/acp/status')) { return jsonResponse(200, acpStatus); } + if ( + req.url.endsWith('/workspace/runtime/ensure') || + req.url.endsWith('/workspace/runtime/status') + ) { + return jsonResponse(200, runtimeStatus); + } if (req.url.endsWith('/workspace/providers')) { return jsonResponse(200, providers); } @@ -1101,12 +1114,20 @@ describe('DaemonClient', () => { await expect(client.workspaceSkills()).resolves.toEqual(skills); await expect(client.workspaceAcpPreheat(1234)).resolves.toEqual(preheat); await expect(client.workspaceAcpStatus()).resolves.toEqual(acpStatus); + await expect(client.ensureWorkspaceRuntime()).resolves.toEqual( + runtimeStatus, + ); + await expect(client.workspaceRuntimeStatus()).resolves.toEqual( + runtimeStatus, + ); await expect(client.workspaceProviders()).resolves.toEqual(providers); expect(calls.map((c) => [c.method, c.url])).toEqual([ ['GET', 'http://daemon/workspace/mcp'], ['GET', 'http://daemon/workspace/skills'], ['POST', 'http://daemon/workspace/acp/preheat?timeoutMs=1234'], ['GET', 'http://daemon/workspace/acp/status'], + ['POST', 'http://daemon/workspace/runtime/ensure'], + ['GET', 'http://daemon/workspace/runtime/status'], ['GET', 'http://daemon/workspace/providers'], ]); }); @@ -1160,6 +1181,68 @@ describe('DaemonClient', () => { }, ); + it.each(['acp-http', 'acp-ws'] as const)( + 'uses REST for primary and qualified runtime routes with %s', + async (transportType) => { + const runtimeStatus = { + v: 1 as const, + workspaceCwd: '/work/secondary', + state: 'idle' as const, + runtimeLive: true, + runtimeEpoch: 2, + }; + const { fetch: restFetch, calls } = recordingFetch(() => + jsonResponse(200, runtimeStatus), + ); + const transportFetch = vi.fn(async () => + jsonResponse(404, { error: 'ACP transport route not found' }), + ); + const transport: DaemonTransport = { + type: transportType, + supportsReplay: transportType === 'acp-http', + connected: true, + restFetch, + fetch: transportFetch, + async *subscribeEvents() {}, + dispose() {}, + }; + const client = new DaemonClient({ + baseUrl: 'http://daemon', + token: 'secret', + transport, + }); + const workspace = client.workspaceByCwd('/work/secondary'); + + await expect(client.ensureWorkspaceRuntime()).resolves.toEqual( + runtimeStatus, + ); + await expect(client.workspaceRuntimeStatus()).resolves.toEqual( + runtimeStatus, + ); + await expect(workspace.ensureRuntime()).resolves.toEqual(runtimeStatus); + await expect(workspace.runtimeStatus()).resolves.toEqual(runtimeStatus); + + expect(calls.map((call) => [call.method, call.url])).toEqual([ + ['POST', 'http://daemon/workspace/runtime/ensure'], + ['GET', 'http://daemon/workspace/runtime/status'], + [ + 'POST', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/ensure', + ], + [ + 'GET', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/status', + ], + ]); + expect( + calls.every( + (call) => call.headers['authorization'] === 'Bearer secret', + ), + ).toBe(true); + expect(transportFetch).not.toHaveBeenCalled(); + }, + ); + it('reloads primary and workspace-qualified MCP settings over REST', async () => { const result = { accepted: true }; const { fetch, calls } = recordingFetch(() => jsonResponse(202, result)); @@ -1499,6 +1582,49 @@ describe('DaemonClient', () => { }); }); + it('gives runtime ensure the server deadline plus client headroom', async () => { + vi.useFakeTimers(); + try { + let resolveResponse: ((value: Response) => void) | undefined; + let requestSignal: AbortSignal | null | undefined; + const slowFetch = vi.fn( + (_input: RequestInfo | URL, init?: { signal?: AbortSignal | null }) => + new Promise((resolve, reject) => { + resolveResponse = resolve; + requestSignal = init?.signal; + init?.signal?.addEventListener('abort', () => { + reject( + init.signal!.reason ?? + new DOMException('aborted', 'AbortError'), + ); + }); + }), + ); + const client = new DaemonClient({ + baseUrl: 'http://daemon', + fetch: slowFetch as unknown as typeof globalThis.fetch, + fetchTimeoutMs: 1, + }); + + const inflight = client.ensureWorkspaceRuntime(); + await vi.advanceTimersByTimeAsync(61_999); + expect(requestSignal?.aborted).toBe(false); + resolveResponse?.( + jsonResponse(200, { + v: 1, + workspaceCwd: '/work/a', + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + }), + ); + + await expect(inflight).resolves.toMatchObject({ runtimeLive: true }); + } finally { + vi.useRealTimers(); + } + }); + it('GETs /workspace/preflight and returns the preflight envelope unchanged', async () => { const preflight: DaemonWorkspacePreflightStatus = { v: 1, From e11f8125ec913f6bf41d5d95aaf61c46ed5ecfe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Tue, 1 Sep 2026 13:47:17 +0800 Subject: [PATCH 2/5] feat(serve): add workspace-scoped MCP management --- docs/design/mcp-management-runtime-model.md | 29 ++ docs/design/workspace-runtime-architecture.md | 81 +-- packages/acp-bridge/src/bridge.test.ts | 226 +++++++- packages/acp-bridge/src/bridge.ts | 233 +++++---- packages/acp-bridge/src/bridgeErrors.ts | 7 + packages/acp-bridge/src/bridgeOptions.ts | 7 + packages/acp-bridge/src/status.ts | 14 + .../cli/src/acp-integration/acpAgent.test.ts | 85 ++- packages/cli/src/acp-integration/acpAgent.ts | 18 +- packages/cli/src/serve/acp-session-bridge.ts | 1 + .../src/serve/routes/workspace-mcp-config.ts | 492 ++++++++++++++++++ .../src/serve/routes/workspace-runtime-mcp.ts | 253 +++++++++ .../src/serve/routes/workspace-settings.ts | 6 +- packages/cli/src/serve/run-qwen-serve.test.ts | 26 + packages/cli/src/serve/run-qwen-serve.ts | 14 + packages/cli/src/serve/server.ts | 19 + .../src/serve/server/error-response.test.ts | 17 +- .../cli/src/serve/server/error-response.ts | 8 + .../serve/workspace-qualified-rest.test.ts | 199 ++++++- .../workspace-runtime-coordinator.test.ts | 316 +++++++++++ .../serve/workspace-runtime-coordinator.ts | 276 +++++++++- .../sdk-typescript/src/daemon/DaemonClient.ts | 161 ++++++ packages/sdk-typescript/src/daemon/index.ts | 4 + packages/sdk-typescript/src/daemon/types.ts | 36 ++ packages/sdk-typescript/src/index.ts | 4 + .../test/unit/DaemonClient.test.ts | 86 +++ packages/web-shell/client/App.test.tsx | 467 ++++++++++++++++- packages/web-shell/client/App.tsx | 20 - .../client/components/mcp/McpManagerPage.tsx | 394 +++++++------- .../components/messages/McpStatusMessage.tsx | 81 ++- .../plugins/PluginManagerPage.test.tsx | 175 +++++++ .../components/plugins/PluginManagerPage.tsx | 148 +++--- .../DaemonWorkspaceProvider.test.tsx | 43 +- .../client/daemon/workspace/actions.test.ts | 96 +++- .../client/daemon/workspace/actions.ts | 197 ++++--- .../daemon/workspace/hooks/useDaemonMcp.ts | 142 ++++- .../client/daemon/workspace/types.ts | 23 +- packages/web-shell/client/i18n.tsx | 8 +- 38 files changed, 3849 insertions(+), 563 deletions(-) create mode 100644 packages/cli/src/serve/routes/workspace-mcp-config.ts create mode 100644 packages/cli/src/serve/routes/workspace-runtime-mcp.ts create mode 100644 packages/web-shell/client/components/plugins/PluginManagerPage.test.tsx diff --git a/docs/design/mcp-management-runtime-model.md b/docs/design/mcp-management-runtime-model.md index 106569ce449..a407f768b00 100644 --- a/docs/design/mcp-management-runtime-model.md +++ b/docs/design/mcp-management-runtime-model.md @@ -18,3 +18,32 @@ Server provenance remains distinct: user settings, workspace settings, project `.mcp.json`, and extensions. Disabling project or workspace servers writes the exclusion to workspace-local settings without modifying the shared project file. + +## Daemon API ownership + +Durable MCP configuration uses `/workspace/config/mcp/*` for user scope and +`/workspaces/:workspace/config/mcp/*` for workspace scope. Runtime observation +and control use the matching `/runtime/mcp/*` routes. Qualified routes always +resolve the selected workspace and never fall back to the primary runtime. +Runtime operations and workspace-scoped configuration mutations require trust; +daemon-local configuration reads and user-scoped mutations do not. + +Each workspace coordinator serializes MCP preparation, reload, restart, and +authentication against its physical ACP runtime. Its capability status exposes +an operation `revision` and the observed `runtimeEpoch`; consumers may render +them but do not send them back. The revision rejects stale results from an +older MCP operation, while the epoch rejects results from a replaced ACP child. +A result is ready only when discovery is complete and came from the current +live epoch. Configuration written while the runtime is cold is durable and +reconciles on the next ensure. Configuration written during a drain is replayed +if the drain rolls back. + +OAuth remains owned by each workspace's ACP child, while daemon-global +admission serializes authentication because every child uses the same callback +port. The owning Bridge retains admission until authentication is observed as +settled or its runtime exits. + +The Web Shell MCP manager uses configuration routes for add, edit, remove, +enable, and disable, and runtime routes for status, discovery, tools, +resources, restart, approval, and OAuth. Existing legacy MCP routes remain as +a compatibility surface for older clients. diff --git a/docs/design/workspace-runtime-architecture.md b/docs/design/workspace-runtime-architecture.md index 85172500c59..760bf50c8a2 100644 --- a/docs/design/workspace-runtime-architecture.md +++ b/docs/design/workspace-runtime-architecture.md @@ -6,12 +6,12 @@ Workspace-runtime-centric 的目标设计与堆叠交付契约。该设计由四个可独立合并的 PR 渐进落地;本文描述最终形态,不表示 foundation PR 已实现所有 capability。 -> **当前落地进度(foundation)**:已实现 Bridge 权威的五态 lifecycle snapshot、 +> **当前落地进度(Foundation + MCP)**:已实现 Bridge 权威的五态 lifecycle snapshot、 > workspace 级单调 epoch、完整物理 work lease、绝对启动 deadline、无参数 > `ensure/status`、10 分钟可续期保活、drain/removal/shutdown admission,以及 -> SDK 的 primary/qualified runtime 方法。第 10~13 节描述的 capability -> generation/revision、Catalog 投影和 operation 状态机仍属于后续阶段;当前 -> `ensure` 只保证 ACP runtime 已初始化并可复用,不宣称各领域 Catalog 已 ready。 +> SDK 的 primary/qualified runtime 方法,以及 MCP 的 revision、Catalog 和 +> runtime management。当前 `ensure` 会在同一观察预算内准备 MCP;Extensions、 +> Skills、Tools 的 generation/revision、Catalog 投影和 operation 状态机仍属于后续阶段。 ### 1.1 当前实现与目标设计 @@ -21,14 +21,14 @@ PR 渐进落地;本文描述最终形态,不表示 foundation PR 已实现 - **Target(未实现)**:后续 PR 的目标契约,当前调用方不得依赖; - **Legacy(兼容)**:迁移期间保留的旧入口,新调用方不应采用。 -| 领域 | Foundation(已实现) | Target(后续阶段) | -| ----------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| Runtime lifecycle | Bridge 权威五态、workspace 单调 epoch、物理 work lease、启动 deadline、drain/removal admission | capability 健康状态参与统一对外投影 | -| `ensure` | 无参数;仅确保 ACP Channel 完成 initialize;成功后续期 10 分钟 | 准备 Extensions、MCP、Skills、Tools,并通过 capability status 表达收敛 | -| `status` | lifecycle、`runtimeLive`、`runtimeEpoch` 快照 | capability、generation、revision、error 和 operation 投影 | -| SDK | primary/qualified `ensure` 与 `status` 的 REST 方法 | config/runtime Catalog、operation 和统一 deadline 的完整 owner-aware API | +| 领域 | Foundation(已实现) | Target(后续阶段) | +| ----------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Runtime lifecycle | Bridge 权威五态、workspace 单调 epoch、物理 work lease、启动 deadline、drain/removal admission | capability 健康状态参与统一对外投影 | +| `ensure` | 无参数;确保 ACP Channel 完成 initialize 并准备 MCP;成功后续期 10 分钟 | 准备 Extensions、Skills、Tools,并通过 capability status 表达收敛 | +| `status` | lifecycle、`runtimeLive`、`runtimeEpoch` 与 MCP capability 快照 | 其他 capability、generation、revision、error 和 operation 投影 | +| SDK | primary/qualified runtime 与 MCP config/runtime 方法 | 其他 Catalog、operation 和统一 deadline 的完整 owner-aware API | -除明确标为 Foundation 的段落外,第 9~14 节中 capability、Catalog、generation、 +除明确标为当前实现的 MCP 契约外,第 9~14 节中其他 capability、Catalog、generation、 revision 和 operation 的详细状态机均是 Target 契约。 核心目标只有一个: @@ -386,9 +386,9 @@ startup preheat 是迁移期策略,不是目标架构的启动入口。完成 ### 8.4 Draining 与移除 -Foundation 的 workspace removal activity 已包含 Session 之外的 Bridge 物理 work 和 -在途 ensure。非 `force` 移除遇到这些活动项返回 `workspace_busy`。进入 `draining` -后,Registry 阻止新的路由解析, +当前组合实现的 workspace removal activity 已包含 Session 之外的 Bridge 物理 work、 +在途 ensure 和已接纳的 workspace-scoped management operation。非 `force` 移除遇到 +这些活动项返回 `workspace_busy`。进入 `draining` 后,Registry 阻止新的路由解析, Coordinator 关闭新的 ensure admission;已经解析但尚未开始物理工作的请求以 `workspace_draining` 失败。移除回滚时一并恢复 admission,提交后的强制清理才终止 现有 work。 @@ -510,27 +510,28 @@ POST /workspaces/:workspace/runtime/ensure primary workspace 使用等价的 `POST /workspace/runtime/ensure`。两个入口都拒绝非空 body;调用方不选择 capability,也不传 timeout、keepalive 或初始化顺序。 -#### Foundation(已实现) +#### 当前实现(Foundation + MCP capability) -当前 Coordinator 的职责刻意很薄: +当前 Coordinator: 1. 校验 workspace 已准确解析、受信任且未 draining; 2. 调用 Bridge 的物理 preheat/initialize,等待 ACP Channel handshake 完成; 3. 将本次成功转换为 workspace 级 10 分钟 keepalive;并发调用保留最长窗口; -4. 从 Bridge 读取 lifecycle snapshot 并返回。 +4. 在同一请求观察预算内准备 MCP,并返回 lifecycle 与 `capabilities.mcp`。 -`ensure` 成功只证明 ACP Channel 已完成 initialize 且可由后续 Session、MCP、Skills -等请求复用;它不证明 MCP discovery、Extension refresh 或任一 Catalog 已 ready。 -若同一物理启动正在进行,并发 `ensure` 复用 Bridge 的启动 Promise;每个成功调用都 -从自己的成功时刻续期 keepalive。若启动卡住,Bridge 的绝对启动 deadline 会中止并 -清理该次启动,后续显式 `ensure` 可以发起新的尝试。 +`ensure` 成功证明 ACP Channel 已完成 initialize,并启动或继续当前 MCP revision 的 +准备。只有 `capabilities.mcp.state === 'ready'`、epoch 与 lifecycle 一致且 MCP status +来自 live runtime 时,调用方才可读取 MCP Catalog。若同一物理启动正在进行,并发 +`ensure` 复用 Bridge 的启动 Promise;每个成功调用都从自己的成功时刻续期 +keepalive。若启动卡住,Bridge 的绝对启动 deadline 会中止并清理该次启动,后续显式 +`ensure` 可以发起新的尝试。 -服务端观察预算为 60 秒。预算耗尽时请求以可重试的 -`runtime_still_starting` 错误结束;底层物理启动仍由独立的绝对启动 -deadline 约束。当前没有 capability 后台收敛 operation,也不会返回 capability -`starting`。`GET /runtime/status` 只观察 lifecycle,不启动或重试 runtime。 +服务端观察预算为 60 秒。ACP Channel 尚未就绪时,预算耗尽以可重试的 +`runtime_still_starting` 错误结束;Channel 已就绪但 MCP 尚未完成时,返回 capability +`starting`,后台继续有界收敛。`GET /runtime/status` 只观察 lifecycle 和 capability, +不启动或重试 runtime。 -#### Target(未实现) +#### Target(部分实现) 后续 Coordinator 将在同一次 workspace runtime command 中固定准备标准能力 `extensions -> (mcp, skills, tools)`: @@ -541,10 +542,11 @@ deadline 约束。当前没有 capability 后台收敛 operation,也不会返 4. 初始化标准 capability 集合并更新可轮询状态; 5. 命令完成、失败或安全排空后释放 lease。 +MCP 已按 revision + epoch 实现上述收敛;Extensions、Skills 与 Tools 尚未迁移。 Target 中 Coordinator 先 prepare Extensions,再并行处理其派生能力;同一 capability 的并发工作合并。HTTP 观察预算耗尽可以先返回 capability `starting`,后台收敛受另一 个固定 deadline 约束,客户端通过 `/runtime/status` 观察终态。此语义在 capability -Coordinator 落地前不得由 SDK/UI 假设。 +Coordinator 落地前不得由 SDK/UI 假设;当前只适用于已接入的 MCP。 按 capability 的 prepare 只是 Coordinator 的内部实现,不暴露 HTTP 或 SDK 接口。 新增 capability 时只修改 Coordinator 的标准能力集合和初始化逻辑。 @@ -593,8 +595,9 @@ deadline;前者到达可先返回 `starting`,后者不随 poll 重置。MCP HTTP/SDK 请求超时与 operation deadline 是不同概念: -- Foundation ensure 的 HTTP 预算结束时返回可重试错误;Target capability ensure - 才返回 `starting`;命令型 operation 返回 `operationId`; +- ACP 物理启动在 HTTP 预算结束时返回可重试错误;已实现的 MCP capability prepare + 可以返回 `starting`,其他 Target capability 后续采用相同语义;命令型 operation + 返回 `operationId`; - 请求断开不自动宣告 operation 失败; - operation 是否继续、取消或超时由其 deadline 和取消策略决定; - UI 通过 operation/status 查询观察终态。 @@ -946,17 +949,16 @@ Catalog GET 始终保持只读,不以“页面加载”为理由启动 ACP。 - 让 WorkspaceRuntime 聚合成为唯一运行时边界; - Bridge 收口 channel、epoch、物理 lease 和 idle 回收; -- Coordinator 只投影 runtime lifecycle,并提供无 capability 参数的 ensure/status; +- Foundation Coordinator 投影 runtime lifecycle,并提供无 capability 参数的 ensure/status; - 将 Coordinator 纳入 workspace drain、removal 和 daemon shutdown; - 保持现有领域 mutation 路由不变,不在 foundation 中引入 MCP、Extension 或 Skills 专属状态。 -### 阶段二:收口状态与 operation(未开始) +### 阶段二:收口状态与 operation(部分完成) -- Coordinator 增加 capability、Extension generation、MCP/Skills revision 和 - operation 投影; -- 所有 capability 状态绑定 epoch; -- 清除跨 epoch ready 合并; +- 已完成:Coordinator 增加 MCP capability 与 revision 投影并绑定 epoch,清除 MCP + 跨 epoch ready 合并; +- 待完成:Extension generation、Skills revision 和 operation 投影; - 将 Extension desired/applied generation 更新改为同一 runtime 回执,MCP/Skills 使用 revision + epoch 丢弃迟到结果; - 使用绝对 deadline;幂等收敛由 status、命令型长任务由 operation 暴露终态; @@ -966,10 +968,11 @@ Catalog GET 始终保持只读,不以“页面加载”为理由启动 ACP。 ### 阶段三:迁移模块和 SDK(部分完成) - 已完成:Workspace SDK 的 foundation ensure/status 对 runtime 路由显式使用 REST; -- 待完成:SDK config/runtime Catalog、operation 和 capability status 契约; +- 已完成:SDK MCP config/runtime Catalog 与 capability status 契约; +- 待完成:其他模块的 config/runtime Catalog 与 operation 契约; - 待完成:Skills snapshot 增加 live source、runtime epoch 和 revision freshness; -- 待完成:Web Shell 接入 primary/qualified ensure; -- Extensions、MCP、Skills 页面迁移到统一 config/runtime/operation 模型; +- 已完成:Web Shell MCP 页面接入 primary/qualified ensure 和统一 config/runtime 模型; +- 待完成:Extensions、Skills 页面迁移到统一 config/runtime/operation 模型; - 管理区域以无参数 runtime ensure 启动完整标准能力,各页面不再传 capability; - Extensions 页面从 runtime Catalog 取得实际状态,不从 config refresh 调 Bridge; - User scope 状态失效覆盖所有受影响工作区;可选事件也要 fan out; diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 02d285c0fce..66b7bf935f2 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -33,6 +33,7 @@ import { InvalidPermissionOptionError, InvalidSessionMetadataError, InvalidSessionScopeError, + McpAuthenticationInProgressError, NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE, PromptDeadlineExceededError, PromptQueueFullError, @@ -1644,10 +1645,16 @@ describe('createAcpSessionBridge', () => { const bridge = makeBridge({ channelFactory }); await bridge.initializeWorkspaceMcp(); - await bridge.queryWorkspaceStatus( - SERVE_STATUS_EXT_METHODS.workspaceMcp, - () => ({ discoveryState: 'not_started', servers: [] }), - ); + await expect( + bridge.queryWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + () => ({ discoveryState: 'not_started', servers: [] }), + ), + ).resolves.toMatchObject({ + discoveryState: 'completed', + runtimeEpoch: 1, + source: 'live', + }); expect(handle.killed).toBe(true); await expect( @@ -1655,7 +1662,11 @@ describe('createAcpSessionBridge', () => { SERVE_STATUS_EXT_METHODS.workspaceMcp, () => ({ discoveryState: 'not_started', servers: [] }), ), - ).resolves.toMatchObject({ discoveryState: 'completed' }); + ).resolves.toMatchObject({ + discoveryState: 'completed', + runtimeEpoch: 1, + source: 'cache', + }); expect(channelFactory).toHaveBeenCalledTimes(1); expect( handle.agent.extMethodCalls.filter( @@ -1667,7 +1678,184 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('preserves other MCP states after disabling in a fresh child', async () => { + it('does not reuse completed MCP status after the runtime epoch changes', async () => { + const first = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize) { + return { accepted: true }; + } + if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { + return { discoveryState: 'completed', servers: [] }; + } + return {}; + }, + }); + const second = makeChannel({ + extMethodImpl: async (method) => + method === SERVE_STATUS_EXT_METHODS.workspaceMcp + ? { discoveryState: 'not_started', servers: [] } + : {}, + }); + const channelFactory = vi + .fn() + .mockResolvedValueOnce(first.channel) + .mockResolvedValueOnce(second.channel); + const bridge = makeBridge({ channelFactory }); + + await bridge.initializeWorkspaceMcp(); + await expect( + bridge.queryWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + () => ({ discoveryState: 'not_started', servers: [] }), + ), + ).resolves.toMatchObject({ + discoveryState: 'completed', + runtimeEpoch: 1, + }); + expect(first.killed).toBe(true); + + await bridge.preheat({ keepAliveMs: 600_000 }); + await expect( + bridge.queryWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + () => ({ discoveryState: 'not_started', servers: [] }), + ), + ).resolves.toMatchObject({ + discoveryState: 'not_started', + runtimeEpoch: 2, + source: 'live', + }); + + await bridge.shutdown(); + }); + + it('serializes MCP authentication across workspace bridges', async () => { + let authenticationActive = false; + const acquireMcpAuthentication = () => { + if (authenticationActive) return undefined; + authenticationActive = true; + return () => { + authenticationActive = false; + }; + }; + const makeAuthenticationChannel = () => + makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpManage) { + return { + serverName: 'aone', + action: 'authenticate', + ok: true, + pending: true, + }; + } + if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { + return { + discoveryState: 'completed', + servers: [{ name: 'aone', authenticationState: 'pending' }], + }; + } + return {}; + }, + }); + const firstHandle = makeAuthenticationChannel(); + const secondHandle = makeAuthenticationChannel(); + const first = makeBridge({ + channelFactory: async () => firstHandle.channel, + acquireMcpAuthentication, + }); + const second = makeBridge({ + boundWorkspace: WS_B, + channelFactory: async () => secondHandle.channel, + acquireMcpAuthentication, + }); + + await expect( + first.manageMcpServer('aone', 'authenticate', undefined), + ).resolves.toMatchObject({ pending: true }); + await expect( + second.manageMcpServer('aone', 'authenticate', undefined), + ).rejects.toBeInstanceOf(McpAuthenticationInProgressError); + + await first.shutdown(); + await expect( + second.manageMcpServer('aone', 'authenticate', undefined), + ).resolves.toMatchObject({ pending: true }); + await second.shutdown(); + }); + + it('keeps MCP authentication admission after a timed-out RPC until its channel exits', async () => { + vi.useFakeTimers(); + try { + let authenticationActive = false; + const acquireMcpAuthentication = () => { + if (authenticationActive) return undefined; + authenticationActive = true; + return () => { + authenticationActive = false; + }; + }; + const stalledAuthentication = deferred>(); + const firstHandle = makeChannel({ + extMethodImpl: async (method) => + method === SERVE_CONTROL_EXT_METHODS.workspaceMcpManage + ? await stalledAuthentication.promise + : {}, + }); + const secondHandle = makeChannel({ + extMethodImpl: async (method) => { + if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpManage) { + return { + serverName: 'aone', + action: 'authenticate', + ok: true, + pending: true, + }; + } + if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { + return { + discoveryState: 'completed', + servers: [{ name: 'aone', authenticationState: 'pending' }], + }; + } + return {}; + }, + }); + const first = makeBridge({ + channelFactory: async () => firstHandle.channel, + acquireMcpAuthentication, + }); + const second = makeBridge({ + boundWorkspace: WS_B, + channelFactory: async () => secondHandle.channel, + acquireMcpAuthentication, + }); + const session = await first.spawnOrAttach({ workspaceCwd: WS_A }); + + const request = first.manageMcpServer('aone', 'authenticate', undefined); + void request.catch(() => undefined); + await vi.advanceTimersByTimeAsync(600_000); + + await expect(request).rejects.toBeInstanceOf(BridgeTimeoutError); + expect(firstHandle.killed).toBe(false); + await expect( + second.manageMcpServer('aone', 'authenticate', undefined), + ).rejects.toBeInstanceOf(McpAuthenticationInProgressError); + + await first.closeSession(session.sessionId); + await vi.waitFor(() => expect(firstHandle.killed).toBe(true)); + await expect( + second.manageMcpServer('aone', 'authenticate', undefined), + ).resolves.toMatchObject({ pending: true }); + + await first.shutdown(); + await second.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not reuse other MCP states after disabling in a fresh child', async () => { const discovery = makeChannel({ extMethodImpl: async (method) => { if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize) { @@ -1742,7 +1930,9 @@ describe('createAcpSessionBridge', () => { () => ({ discoveryState: 'not_started', servers: [] }), ), ).resolves.toMatchObject({ - discoveryState: 'completed', + discoveryState: 'not_started', + runtimeEpoch: 2, + source: 'cache', servers: [ expect.objectContaining({ name: 'aone', @@ -1752,7 +1942,7 @@ describe('createAcpSessionBridge', () => { expect.objectContaining({ name: 'yuque', disabled: false, - mcpStatus: 'connected', + mcpStatus: 'disconnected', }), ], }); @@ -1761,7 +1951,7 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('preserves other MCP states while polling authentication', async () => { + it('does not reuse other MCP states while polling in a fresh child', async () => { const discovery = makeChannel({ extMethodImpl: async (method) => { if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize) { @@ -1827,14 +2017,14 @@ describe('createAcpSessionBridge', () => { () => ({ discoveryState: 'not_started', servers: [] }), ), ).resolves.toMatchObject({ - discoveryState: 'completed', + discoveryState: 'not_started', servers: [ expect.objectContaining({ name: 'aone', mcpStatus: 'connected', authenticationState: 'succeeded', }), - expect.objectContaining({ name: 'yuque', mcpStatus: 'connected' }), + expect.objectContaining({ name: 'yuque', mcpStatus: 'disconnected' }), ], }); expect(authentication.killed).toBe(true); @@ -1842,7 +2032,7 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); - it('preserves other MCP states after restarting in a fresh child', async () => { + it('does not reuse other MCP states after restarting in a fresh child', async () => { const discovery = makeChannel({ extMethodImpl: async (method) => { if (method === SERVE_CONTROL_EXT_METHODS.workspaceMcpInitialize) { @@ -1899,10 +2089,10 @@ describe('createAcpSessionBridge', () => { () => ({ discoveryState: 'not_started', servers: [] }), ), ).resolves.toMatchObject({ - discoveryState: 'completed', + discoveryState: 'not_started', servers: [ expect.objectContaining({ name: 'aone', mcpStatus: 'connected' }), - expect.objectContaining({ name: 'yuque', mcpStatus: 'connected' }), + expect.objectContaining({ name: 'yuque', mcpStatus: 'disconnected' }), ], }); @@ -31993,7 +32183,7 @@ describe('preheat', () => { await secondBridge.shutdown(); }); - it('keeps an idle-timeout-zero channel alive while workspace status is pending', async () => { + it('reaps an idle-timeout-zero preheat after workspace status settles', async () => { const statusResult = deferred>(); const statusMethod = 'qwen/status/workspace/test'; const handle = makeChannel({ @@ -32026,10 +32216,10 @@ describe('preheat', () => { statusResult.resolve({ ready: true }); await expect(status).resolves.toEqual({ ready: true }); - expect(handle.killed).toBe(false); + expect(handle.killed).toBe(true); expect(bridge.getWorkspaceRuntimeLifecycleSnapshot!()).toMatchObject({ - state: 'idle', - runtimeLive: true, + state: 'cold', + runtimeLive: false, activeWork: false, }); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index c97e1092ac8..7713313f881 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -115,6 +115,7 @@ import { InvalidRewindTargetError, PromptDeadlineExceededError, BridgeChannelQuarantinedError, + McpAuthenticationInProgressError, StandaloneSessionSpawnError, } from './bridgeErrors.js'; import type { BridgeChannelUnavailableReason } from './bridgeErrors.js'; @@ -915,14 +916,13 @@ interface ChannelInfo { sessionSpawnsInFlight: number; /** Workspace-level control calls that use the shared channel without a session. */ workspaceControlInFlight: number; - /** A plain preheat keeps the channel through its first status read. */ - preserveForFirstStatusRead: boolean; /** Background MCP discovery started by the workspace initialize control. */ workspaceMcpDiscoveryInFlight: boolean; workspaceMcpDiscoveryTimer?: NodeJS.Timeout; workspaceMcpDiscoveryRequested: boolean; workspaceMcpAuthenticationServerNames: Set; workspaceMcpAuthenticationTimers: Map; + workspaceMcpAuthenticationReleases: Map void>; /** A timed-out workspace operation will retire this channel after Sessions drain. */ retireWhenSessionsDrain: boolean; /** @@ -3807,13 +3807,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { async function withWorkspaceControl( ci: ChannelInfo, fn: () => Promise, - statusRead = false, ): Promise { - const preservePreheat = - statusRead && - ci.preserveForFirstStatusRead && - resolvedChannelIdleTimeoutMs() <= 0; - ci.preserveForFirstStatusRead = false; if (liveChannelInfo() === ci) cancelIdleTimer(); ci.workspaceControlInFlight++; try { @@ -3827,12 +3821,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ci.workspaceControlInFlight - 1, ); await reapPendingEmptyChannel(ci); - if ( - !preservePreheat && - !ci.isDying && - liveChannelInfo() === ci && - hasNoChannelWork(ci) - ) { + if (!ci.isDying && liveChannelInfo() === ci && hasNoChannelWork(ci)) { await startIdleTimer(ci, 'workspace control'); } } @@ -3854,7 +3843,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ci: ChannelInfo, fn: () => Promise, ): Promise { - return withWorkspaceControl(ci, fn, true); + return withWorkspaceControl(ci, fn); } function startSessionReaper(): void { @@ -4574,11 +4563,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { pendingRestoreIds: new Set(), sessionSpawnsInFlight: 0, workspaceControlInFlight: 0, - preserveForFirstStatusRead: false, workspaceMcpDiscoveryInFlight: false, workspaceMcpDiscoveryRequested: false, workspaceMcpAuthenticationServerNames: new Set(), workspaceMcpAuthenticationTimers: new Map(), + workspaceMcpAuthenticationReleases: new Map(), retireWhenSessionsDrain: false, emptyReapPending: false, unsettledAbandonedRestores: new Set(), @@ -4660,6 +4649,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } info.workspaceMcpAuthenticationTimers.clear(); info.workspaceMcpAuthenticationServerNames.clear(); + for (const release of info.workspaceMcpAuthenticationReleases.values()) { + release(); + } + info.workspaceMcpAuthenticationReleases.clear(); for (const timer of info.restoreSettlementTimers.values()) { clearTimeout(timer); } @@ -5205,7 +5198,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (ci.isDying) { throw new BridgeChannelClosedError('before newSession'); } - ci.preserveForFirstStatusRead = false; ci.sessionSpawnsInFlight++; if (requestedSessionId !== undefined) { // A caller-supplied id can legitimately reuse an id after an abandoned @@ -6068,9 +6060,16 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if ( Array.isArray(current.servers) && previous?.discoveryState === 'completed' && + previous.runtimeEpoch === current.runtimeEpoch && current.discoveryState === 'not_started' ) { - if (current.servers.length === 0) return previous; + if (current.servers.length === 0) { + return { + ...previous, + runtimeEpoch: current.runtimeEpoch, + source: current.source, + }; + } const currentServers = new Map( current.servers.map((server) => [server.name, server]), ); @@ -6089,6 +6088,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } return { ...previous, + runtimeEpoch: current.runtimeEpoch, + source: current.source, discoveryState: 'completed', servers, }; @@ -6108,7 +6109,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { method === SERVE_STATUS_EXT_METHODS.workspaceMcp && workspaceMcpStatusCache ) { - return workspaceMcpStatusCache as T; + return { + ...workspaceMcpStatusCache, + source: 'cache', + } as T; } return idle(); } @@ -6124,6 +6128,20 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { initTimeoutMs, method, ); + if ( + isRecord(response) && + (method === SERVE_STATUS_EXT_METHODS.workspaceMcp || + method === SERVE_STATUS_EXT_METHODS.workspaceMcpTools || + method === SERVE_STATUS_EXT_METHODS.workspaceMcpResources) + ) { + response = { + ...response, + runtimeEpoch, + ...(method === SERVE_STATUS_EXT_METHODS.workspaceMcp + ? { source: 'live' } + : {}), + }; + } if (method === SERVE_STATUS_EXT_METHODS.workspaceMcp) { const rawStatus = response as unknown as ServeWorkspaceMcpStatus; if (!Array.isArray(rawStatus.servers)) { @@ -6134,12 +6152,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...info.workspaceMcpAuthenticationServerNames, ...(managedServerNames ?? []), ]); - if ( - effectiveManagedServerNames.size > 0 || - (workspaceMcpStatusCache?.discoveryState === 'completed' && - rawStatus.discoveryState === 'not_started' && - rawServers.length === 0) - ) { + if (effectiveManagedServerNames.size > 0) { response = mergeManagedWorkspaceMcpStatus( effectiveManagedServerNames, workspaceMcpStatusCache, @@ -6189,6 +6202,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const timer = info.workspaceMcpAuthenticationTimers.get(serverName); if (timer) clearTimeout(timer); info.workspaceMcpAuthenticationTimers.delete(serverName); + info.workspaceMcpAuthenticationReleases.get(serverName)?.(); + info.workspaceMcpAuthenticationReleases.delete(serverName); } } workspaceMcpStatusCache = @@ -8050,7 +8065,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new BridgeChannelClosedError(`before session/${action}`); } ci = restoreChannel; - restoreChannel.preserveForFirstStatusRead = false; restoreChannel.pendingRestoreIds.add(req.sessionId); // Mark this id as in-flight restore BEFORE the ACP // `loadSession`/`unstable_resumeSession` call. Restore-time @@ -13553,68 +13567,106 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }, async manageMcpServer(serverName, action, originatorClientId) { - return await withEnsuredWorkspaceControl(async (info) => { - const timeout = - action === 'authenticate' - ? MCP_OAUTH_TIMEOUT_MS - : MCP_RESTART_TIMEOUT_MS; - const response = (await Promise.race([ - withTimeout( - info.connection.extMethod( - SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, - { serverName, action, originatorClientId }, - ), - timeout, - SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, - ), - getChannelClosedReject(info), - ])) as { - serverName: string; - action: - | 'approve' - | 'enable' - | 'disable' - | 'authenticate' - | 'clear-auth'; - ok: true; - changed?: boolean; - messages?: string[]; - authUrl?: string; - pending?: boolean; - }; - if (action === 'authenticate' && response.pending) { - info.workspaceMcpAuthenticationServerNames.add(serverName); - const previousTimer = - info.workspaceMcpAuthenticationTimers.get(serverName); - if (previousTimer) clearTimeout(previousTimer); - const timer = setTimeout(() => { - void expireWorkspaceMcpAuthentication(info, serverName, timer); - }, MCP_OAUTH_TIMEOUT_MS); - timer.unref(); - info.workspaceMcpAuthenticationTimers.set(serverName, timer); - } - invalidateWorkspaceMcpDetailCache(serverName); - await requestWorkspaceStatus( - SERVE_STATUS_EXT_METHODS.workspaceMcp, - () => { - throw new BridgeChannelClosedError( - 'workspace MCP management status refresh', + let releaseAuthentication = + action === 'authenticate' + ? opts.acquireMcpAuthentication?.(boundWorkspace, serverName) + : undefined; + if ( + action === 'authenticate' && + opts.acquireMcpAuthentication && + !releaseAuthentication + ) { + throw new McpAuthenticationInProgressError(); + } + try { + return await withEnsuredWorkspaceControl(async (info) => { + if (releaseAuthentication) { + info.workspaceMcpAuthenticationReleases.set( + serverName, + releaseAuthentication, ); - }, - {}, - new Set([serverName]), - ); - broadcastWorkspaceEvent({ - type: 'mcp_server_changed', - data: { - serverName: response.serverName, - action: response.action, - originatorClientId, - }, - ...(originatorClientId ? { originatorClientId } : {}), + releaseAuthentication = undefined; + } + const timeout = + action === 'authenticate' + ? MCP_OAUTH_TIMEOUT_MS + : MCP_RESTART_TIMEOUT_MS; + let response: { + serverName: string; + action: + | 'approve' + | 'enable' + | 'disable' + | 'authenticate' + | 'clear-auth'; + ok: true; + changed?: boolean; + messages?: string[]; + authUrl?: string; + pending?: boolean; + }; + try { + response = (await Promise.race([ + withTimeout( + info.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, + { serverName, action, originatorClientId }, + ), + timeout, + SERVE_CONTROL_EXT_METHODS.workspaceMcpManage, + ), + getChannelClosedReject(info), + ])) as typeof response; + } catch (error) { + if ( + action === 'authenticate' && + !(error instanceof BridgeTimeoutError) && + !(error instanceof BridgeChannelClosedError) + ) { + info.workspaceMcpAuthenticationReleases.get(serverName)?.(); + info.workspaceMcpAuthenticationReleases.delete(serverName); + } + throw error; + } + if (action === 'authenticate' && response.pending) { + info.workspaceMcpAuthenticationServerNames.add(serverName); + const previousTimer = + info.workspaceMcpAuthenticationTimers.get(serverName); + if (previousTimer) clearTimeout(previousTimer); + const timer = setTimeout(() => { + void expireWorkspaceMcpAuthentication(info, serverName, timer); + }, MCP_OAUTH_TIMEOUT_MS); + timer.unref(); + info.workspaceMcpAuthenticationTimers.set(serverName, timer); + } else if (action === 'authenticate') { + info.workspaceMcpAuthenticationReleases.get(serverName)?.(); + info.workspaceMcpAuthenticationReleases.delete(serverName); + } + invalidateWorkspaceMcpDetailCache(serverName); + await requestWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceMcp, + () => { + throw new BridgeChannelClosedError( + 'workspace MCP management status refresh', + ); + }, + {}, + new Set([serverName]), + ); + broadcastWorkspaceEvent({ + type: 'mcp_server_changed', + data: { + serverName: response.serverName, + action: response.action, + originatorClientId, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + return response; }); - return response; - }); + } finally { + releaseAuthentication?.(); + } }, async initializeWorkspaceMcp() { @@ -14310,19 +14362,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { 'channel.preheat', { 'qwen-code.daemon.bridge.operation': 'channel.preheat' }, async () => { - const existing = liveChannelInfo(); - const info = await ensureChannel(); + await ensureChannel(); if (keepAliveMs !== undefined) { keepAliveUntil = Math.max( keepAliveUntil, Date.now() + keepAliveMs, ); - } else if ( - !existing && - configuredChannelIdleTimeoutMs() === 0 && - hasNoSessionWork(info) - ) { - info.preserveForFirstStatusRead = true; } }, ); diff --git a/packages/acp-bridge/src/bridgeErrors.ts b/packages/acp-bridge/src/bridgeErrors.ts index b90cdccc67d..bb3aec22ef2 100644 --- a/packages/acp-bridge/src/bridgeErrors.ts +++ b/packages/acp-bridge/src/bridgeErrors.ts @@ -704,3 +704,10 @@ export class CdWhilePromptActiveError extends Error { this.sessionId = sessionId; } } + +export class McpAuthenticationInProgressError extends Error { + constructor() { + super('Another MCP authentication is already in progress'); + this.name = 'McpAuthenticationInProgressError'; + } +} diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index f9b94be3d69..edee17dd157 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -99,6 +99,11 @@ export interface BridgeRuntimeEpochSource { allocate(): number; } +export type BridgeMcpAuthenticationAdmission = ( + workspaceCwd: string, + serverName: string, +) => (() => void) | undefined; + /** * Trusted child-to-daemon request made immediately before a tool executor. * `sessionId` and `promptId` are revalidated by BridgeClient against its @@ -256,6 +261,8 @@ export interface BridgeOptions { channelFactory?: ChannelFactory; /** Workspace-scoped epoch source shared across Bridge replacement. */ runtimeEpochSource?: BridgeRuntimeEpochSource; + /** Daemon-global admission for the process-wide MCP OAuth callback port. */ + acquireMcpAuthentication?: BridgeMcpAuthenticationAdmission; /** How long to wait for the child's `initialize` reply before giving up. */ initializeTimeoutMs?: number; /** diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 1e402a754d3..a09dfbcd734 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -10,12 +10,22 @@ import { SkillError } from '@qwen-code/qwen-code-core'; export const STATUS_SCHEMA_VERSION = 1 as const; +export interface ServeWorkspaceRuntimeCapabilityStatus { + state: 'not_started' | 'starting' | 'ready' | 'stale' | 'error'; + revision: number; + runtimeEpoch?: number; + error?: { code: string; message: string }; +} + export interface ServeWorkspaceRuntimeStatus { v: typeof STATUS_SCHEMA_VERSION; workspaceCwd: string; state: 'cold' | 'starting' | 'active' | 'idle' | 'stopping'; runtimeLive: boolean; runtimeEpoch: number; + capabilities?: { + mcp?: ServeWorkspaceRuntimeCapabilityStatus; + }; } /** @@ -417,6 +427,8 @@ export interface ServeWorkspaceMcpStatus { v: typeof STATUS_SCHEMA_VERSION; workspaceCwd: string; initialized: boolean; + runtimeEpoch?: number; + source?: 'live' | 'cache'; discoveryState?: ServeMcpDiscoveryState; servers: ServeWorkspaceMcpServerStatus[]; errors?: ServeStatusCell[]; @@ -449,6 +461,7 @@ export interface ServeWorkspaceMcpToolsStatus { workspaceCwd: string; serverName: string; initialized: boolean; + runtimeEpoch?: number; acpChannelLive: boolean; tools: ServeWorkspaceMcpToolStatus[]; errors?: ServeStatusCell[]; @@ -481,6 +494,7 @@ export interface ServeWorkspaceMcpResourcesStatus { workspaceCwd: string; serverName: string; initialized: boolean; + runtimeEpoch?: number; acpChannelLive: boolean; resources: ServeWorkspaceMcpResourceStatus[]; errors?: ServeStatusCell[]; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index b61d2006f3f..5a1075e40ba 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -37,10 +37,15 @@ const { mockRunExitCleanup } = vi.hoisted(() => ({ const { mockStartNonInteractiveOpenAILogHousekeeping } = vi.hoisted(() => ({ mockStartNonInteractiveOpenAILogHousekeeping: vi.fn(), })); -const { mockMcpPoolDrainAll } = vi.hoisted(() => ({ +const { mockMcpPoolDrainAll, mockMcpPoolGetSnapshot } = vi.hoisted(() => ({ mockMcpPoolDrainAll: vi .fn() .mockResolvedValue({ drained: 0, forced: 0, errors: [] }), + mockMcpPoolGetSnapshot: vi.fn().mockReturnValue({ + total: 0, + subprocessCount: 0, + byName: {}, + }), })); vi.mock('../utils/cleanup.js', () => ({ runExitCleanup: mockRunExitCleanup, @@ -639,11 +644,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ })), McpTransportPool: vi.fn().mockImplementation(() => ({ drainAll: mockMcpPoolDrainAll, - getSnapshot: vi.fn().mockReturnValue({ - total: 0, - subprocessCount: 0, - byName: {}, - }), + getSnapshot: mockMcpPoolGetSnapshot, releaseSession: vi.fn(), restartByName: vi.fn().mockResolvedValue([]), getBudget: vi.fn().mockReturnValue(undefined), @@ -2142,6 +2143,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { mockMcpPoolDrainAll .mockReset() .mockResolvedValue({ drained: 0, forced: 0, errors: [] }); + mockMcpPoolGetSnapshot.mockReset().mockReturnValue({ + total: 0, + subprocessCount: 0, + byName: {}, + }); mockExtensionManagerState.extensions = []; mockExtensionManagerState.refreshCache.mockResolvedValue(undefined); mockRunManagedAutoMemoryDream.mockReset(); @@ -5317,6 +5323,67 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('uses the pool status for the top-level MCP server status', async () => { + const manager = { + getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.COMPLETED), + getMcpClientAccounting: vi.fn().mockReturnValue({ + total: 0, + reservedSlots: [], + refusedServerNames: [], + }), + getMcpClientBudget: vi.fn().mockReturnValue(undefined), + getMcpBudgetMode: vi.fn().mockReturnValue('off'), + getServerStatus: vi.fn().mockReturnValue(MCPServerStatus.DISCONNECTED), + }; + mockConfig = { + ...mockConfig, + getMcpServers: vi + .fn() + .mockReturnValue({ code: { httpUrl: 'https://example.com/mcp' } }), + getWorkingDir: vi.fn().mockReturnValue('/tmp'), + getTargetDir: vi.fn().mockReturnValue('/tmp'), + isMcpServerDisabled: vi.fn().mockReturnValue(false), + getToolRegistry: vi.fn().mockReturnValue({ + getMcpClientManager: vi.fn().mockReturnValue(manager), + }), + } as unknown as Config; + mockMcpPoolGetSnapshot.mockReturnValue({ + total: 1, + subprocessCount: 0, + byName: { + code: { + entryCount: 1, + entrySummary: [ + { + entryIndex: 0, + refs: 1, + status: MCPServerStatus.CONNECTED, + }, + ], + }, + }, + }); + const { agent, agentPromise } = await bootAcpAgent(); + + await expect( + agent.extMethod(SERVE_STATUS_EXT_METHODS.workspaceMcp, {}), + ).resolves.toMatchObject({ + servers: [ + expect.objectContaining({ + name: 'code', + status: 'ok', + mcpStatus: 'connected', + entrySummary: [ + expect.objectContaining({ entryIndex: 0, status: 'connected' }), + ], + }), + ], + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('reports authentication-required MCP servers and refuses reconnect', async () => { const server: { httpUrl: string; @@ -24262,6 +24329,7 @@ describe('QwenAgent extMethod runtime MCP add/remove (T2.8)', () => { }; const runtimeServer = { command: 'runtime-server' }; const forceDiscover = vi.fn().mockResolvedValue(undefined); + const setTools = vi.fn().mockResolvedValue(undefined); let finishDiscovery!: () => void; const discoveryPending = new Promise((resolve) => { finishDiscovery = resolve; @@ -24314,6 +24382,10 @@ describe('QwenAgent extMethod runtime MCP add/remove (T2.8)', () => { .mockReturnValue({ runtime: runtimeServer }); mockConfig.getWorkingDir = vi.fn().mockReturnValue('/tmp'); mockConfig.isMcpServerDisabled = vi.fn().mockReturnValue(false); + mockConfig.getLlmClient = vi.fn().mockReturnValue({ + isInitialized: vi.fn().mockReturnValue(true), + setTools, + }); mockConfig.getToolRegistry = vi.fn().mockReturnValue({ discoverToolsForServer: forceDiscover, getMcpClientManager: vi.fn().mockReturnValue({ @@ -24387,6 +24459,7 @@ describe('QwenAgent extMethod runtime MCP add/remove (T2.8)', () => { discovery: discoveryServer, }), ); + expect(setTools).toHaveBeenCalledOnce(); expect(mockConfig.setExcludedMcpServers).toHaveBeenCalledWith(['disabled']); expect(mockConfig.setAllowedMcpServers).toHaveBeenCalledWith([ 'persisted', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index c75ff300799..770489d390c 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -3927,6 +3927,10 @@ class QwenAgent implements Agent { config.setAllowedMcpServers(gating.allowed); config.setPendingMcpServers(gating.pending); await config.reinitializeMcpServers(mcpServers); + const llmClient = config.getLlmClient?.(); + if (llmClient?.isInitialized?.()) { + await llmClient.setTools?.(); + } } catch (error) { syncErrors.push(error); } @@ -6363,7 +6367,18 @@ class QwenAgent implements Agent { } catch { // Match CLI: token lookup errors should not break /mcp status. } - const rawStatus = this.getMcpServerStatus(config, name); + const poolRow = poolByName[name]; + const rawStatus = poolRow + ? poolRow.entrySummary.some( + (entry) => entry.status === MCPServerStatus.CONNECTED, + ) + ? MCPServerStatus.CONNECTED + : poolRow.entrySummary.some( + (entry) => entry.status === MCPServerStatus.CONNECTING, + ) + ? MCPServerStatus.CONNECTING + : MCPServerStatus.DISCONNECTED + : this.getMcpServerStatus(config, name); const requiresAuth = rawStatus !== MCPServerStatus.CONNECTED && (mcpServerRequiresOAuth.get(name) === true || @@ -6499,7 +6514,6 @@ class QwenAgent implements Agent { } } // Pool entries enrichment. - const poolRow = poolByName[name]; if (poolRow) { out.entryCount = poolRow.entryCount; out.entrySummary = poolRow.entrySummary.map((e) => ({ diff --git a/packages/cli/src/serve/acp-session-bridge.ts b/packages/cli/src/serve/acp-session-bridge.ts index ce29320c783..3277a244be7 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -135,6 +135,7 @@ export { WorkspaceInitPathEscapeError, WorkspaceInitSymlinkError, WorkspaceInitRaceError, + McpAuthenticationInProgressError, McpServerNotFoundError, McpServerRestartFailedError, SessionBusyError, diff --git a/packages/cli/src/serve/routes/workspace-mcp-config.ts b/packages/cli/src/serve/routes/workspace-mcp-config.ts new file mode 100644 index 00000000000..7fa487e1c26 --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-mcp-config.ts @@ -0,0 +1,492 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { matchesAnyServerPattern } from '@qwen-code/qwen-code-core'; +import type { Application, Request, RequestHandler, Response } from 'express'; +import { redactMcpServersSetting } from '../../config/mcp-server-secrets.js'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { + getSettingDefinition, + validateSettingValue, +} from '../../config/settingsUtils.js'; +import type { SendBridgeError } from '../server/error-response.js'; +import { validateMcpRuntimeServerName } from '../server/request-helpers.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceEntryFromParam, + resolveWorkspaceRuntimeFromParam, +} from '../workspace-route-runtime.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; +import { getWorkspaceRuntimeCoordinator } from '../workspace-runtime-coordinator.js'; +import { + prepareSettingWrite, + withMcpServerMutationLock, +} from './workspace-settings.js'; + +interface McpConfigRoutesDeps { + workspaceRegistry: WorkspaceRegistry; + mutate: (opts?: { strict?: boolean }) => RequestHandler; + safeBody: (req: Request) => Record; + persistSetting: ( + workspace: string, + scope: SettingScope, + key: string, + value: unknown, + assertGenerationOpen?: () => void, + ) => Promise; + sendBridgeError: SendBridgeError; + invalidateServeFeaturesCache: () => void; + broadcastSettingsChanged: ( + key: string, + value: unknown, + scope: string, + clientId: string | undefined, + ) => void; +} + +type McpConfigScope = 'user' | 'workspace'; + +interface McpConfigTarget { + workspaceCwd: string; + runtime?: WorkspaceRuntime; +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (redactMcpServersSetting(value) as Record) + : {}; +} + +function buildMcpConfigStatus(workspaceCwd: string) { + const settings = loadSettings(workspaceCwd, { + skipLoadEnvironment: true, + }); + const effective = asRecord(settings.merged.mcpServers); + const user = asRecord(settings.user.settings.mcpServers); + const workspace = asRecord(settings.workspace.settings.mcpServers); + return { + v: 1 as const, + effective, + user, + workspace, + }; +} + +function affectedRuntimes( + registry: WorkspaceRegistry, + target: McpConfigTarget, + scope: McpConfigScope, +): readonly WorkspaceRuntime[] { + return scope === 'user' + ? registry.listManaged().filter((runtime) => runtime.trusted) + : target.runtime + ? [target.runtime] + : []; +} + +function scheduleMcpConfiguration( + runtimes: readonly WorkspaceRuntime[], +): 'deferred' | 'reconciling' { + let reconciling = false; + for (const runtime of runtimes) { + try { + if ( + getWorkspaceRuntimeCoordinator(runtime).reconcileMcpConfiguration() === + 'reconciling' + ) { + reconciling = true; + } + } catch { + // Durable configuration is already committed. + } + } + return reconciling ? 'reconciling' : 'deferred'; +} + +function runManagementOperation( + target: McpConfigTarget, + operation: () => Promise, +): Promise { + return target.runtime + ? getWorkspaceRuntimeCoordinator(target.runtime).runManagementOperation( + operation, + ) + : operation(); +} + +function registerFor( + app: Application, + base: string, + scope: McpConfigScope, + resolveReadTarget: (req: Request, res: Response) => McpConfigTarget | null, + resolveMutationTarget: ( + req: Request, + res: Response, + ) => McpConfigTarget | null, + deps: McpConfigRoutesDeps, +): void { + const settingScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + + app.get(`${base}/config/mcp/servers`, (req, res) => { + const target = resolveReadTarget(req, res); + if (!target) return; + try { + res.status(200).json(buildMcpConfigStatus(target.workspaceCwd)); + } catch (error) { + deps.sendBridgeError(res, error, { + route: `GET ${base}/config/mcp/servers`, + }); + } + }); + + app.put( + `${base}/config/mcp/servers/:name`, + deps.mutate({ strict: true }), + async (req, res) => { + const target = resolveMutationTarget(req, res); + if (!target) return; + const name = req.params['name']; + if (!validateMcpRuntimeServerName(name, res)) return; + const body = deps.safeBody(req); + if (body['scope'] !== scope) { + res.status(400).json({ + error: `scope must be ${scope}`, + code: 'invalid_scope', + }); + return; + } + const config = body['config']; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + res.status(400).json({ + error: 'config is required and must be an object', + code: 'invalid_mcp_server_config', + }); + return; + } + try { + const result = await runManagementOperation(target, async () => { + let publicValue: unknown; + await withMcpServerMutationLock( + target.workspaceCwd, + settingScope, + async () => { + const prepared = prepareSettingWrite( + target.workspaceCwd, + settingScope, + 'mcpServers', + config, + { operation: 'set', name }, + ); + const definition = getSettingDefinition('mcpServers'); + const validationError = definition + ? validateSettingValue(definition, prepared.persistedValue) + : undefined; + if (validationError) { + const error = new Error(validationError) as Error & { + code?: string; + }; + error.code = 'invalid_mcp_server_config'; + throw error; + } + await deps.persistSetting( + target.workspaceCwd, + settingScope, + 'mcpServers', + prepared.persistedValue, + target.runtime?.generationGuard?.assertOpen, + ); + publicValue = prepared.publicValue; + }, + ); + const servers = asRecord(publicValue); + return { + config: servers[name], + publicValue, + activation: scheduleMcpConfiguration( + affectedRuntimes(deps.workspaceRegistry, target, scope), + ), + }; + }); + publishConfiguration( + target, + scope, + 'mcpServers', + result.publicValue, + deps, + ); + res.status(200).json({ + name, + scope, + config: result.config, + activation: result.activation, + }); + } catch (error) { + if ( + error instanceof Error && + (error as Error & { code?: string }).code === + 'invalid_mcp_server_config' + ) { + res.status(400).json({ + error: error.message, + code: 'invalid_mcp_server_config', + }); + return; + } + deps.sendBridgeError(res, error, { + route: `PUT ${base}/config/mcp/servers/:name`, + }); + } + }, + ); + + app.delete( + `${base}/config/mcp/servers/:name`, + deps.mutate({ strict: true }), + async (req, res) => { + const target = resolveMutationTarget(req, res); + if (!target) return; + const name = req.params['name']; + if (!validateMcpRuntimeServerName(name, res)) return; + if (req.query['scope'] !== scope) { + res.status(400).json({ + error: `scope must be ${scope}`, + code: 'invalid_scope', + }); + return; + } + try { + const result = await runManagementOperation(target, async () => { + let publicValue: unknown; + await withMcpServerMutationLock( + target.workspaceCwd, + settingScope, + async () => { + const prepared = prepareSettingWrite( + target.workspaceCwd, + settingScope, + 'mcpServers', + {}, + { operation: 'remove', name }, + ); + await deps.persistSetting( + target.workspaceCwd, + settingScope, + 'mcpServers', + prepared.persistedValue, + target.runtime?.generationGuard?.assertOpen, + ); + publicValue = prepared.publicValue; + }, + ); + return { + publicValue, + activation: scheduleMcpConfiguration( + affectedRuntimes(deps.workspaceRegistry, target, scope), + ), + }; + }); + publishConfiguration( + target, + scope, + 'mcpServers', + result.publicValue, + deps, + ); + res.status(200).json({ name, scope, activation: result.activation }); + } catch (error) { + deps.sendBridgeError(res, error, { + route: `DELETE ${base}/config/mcp/servers/:name`, + }); + } + }, + ); + + for (const action of ['enable', 'disable'] as const) { + app.post( + `${base}/config/mcp/:server/${action}`, + deps.mutate({ strict: true }), + async (req, res) => { + const target = resolveMutationTarget(req, res); + if (!target) return; + const serverName = req.params['server']; + if (!validateMcpRuntimeServerName(serverName, res)) return; + try { + const result = await runManagementOperation(target, async () => { + let changed = false; + let next: string[] = []; + let blocking: string[] = []; + await withMcpServerMutationLock( + target.workspaceCwd, + settingScope, + async () => { + const settings = loadSettings(target.workspaceCwd, { + skipLoadEnvironment: true, + }); + const existing = + settings.forScope(settingScope).settings.mcp?.excluded ?? []; + if (action === 'enable') { + const blockingScopes = [ + SettingScope.SystemDefaults, + SettingScope.System, + SettingScope.User, + ...(settingScope === SettingScope.Workspace + ? [SettingScope.Workspace] + : []), + ]; + blocking = blockingScopes.flatMap((candidateScope) => + ( + settings.forScope(candidateScope).settings.mcp + ?.excluded ?? [] + ).filter( + (pattern) => + matchesAnyServerPattern(serverName, [pattern]) && + (candidateScope !== settingScope || + pattern !== serverName), + ), + ); + if (blocking.length > 0) return; + } + next = + action === 'enable' + ? existing.filter((pattern) => pattern !== serverName) + : existing.includes(serverName) + ? existing + : [...existing, serverName]; + changed = next.length !== existing.length; + if (changed) { + await deps.persistSetting( + target.workspaceCwd, + settingScope, + 'mcp.excluded', + next, + target.runtime?.generationGuard?.assertOpen, + ); + } + }, + ); + if (blocking.length > 0) { + res.status(409).json({ + error: `MCP server is excluded by pattern: ${blocking.join(', ')}`, + code: 'mcp_excluded_by_pattern', + patterns: blocking, + }); + return null; + } + return { + changed, + value: next, + activation: changed + ? scheduleMcpConfiguration( + affectedRuntimes(deps.workspaceRegistry, target, scope), + ) + : ('applied' as const), + }; + }); + if (!result) return; + if (result.changed) { + publishConfiguration( + target, + scope, + 'mcp.excluded', + result.value, + deps, + ); + } + res.status(200).json({ + serverName, + action, + ok: true, + changed: result.changed, + activation: result.activation, + }); + } catch (error) { + deps.sendBridgeError(res, error, { + route: `POST ${base}/config/mcp/:server/${action}`, + }); + } + }, + ); + } +} + +function publishConfiguration( + target: McpConfigTarget, + scope: McpConfigScope, + key: string, + value: unknown, + deps: McpConfigRoutesDeps, +): void { + deps.invalidateServeFeaturesCache(); + if (scope === 'user') { + try { + deps.broadcastSettingsChanged(key, value, scope, undefined); + } catch { + // Durable configuration is already committed. + } + for (const candidate of deps.workspaceRegistry.listManaged()) { + if (candidate.workspaceCwd === target.workspaceCwd) continue; + try { + candidate.bridge.publishWorkspaceEvent({ + type: 'settings_changed', + data: { key, value, scope }, + }); + } catch { + // Durable configuration is already committed. + } + } + return; + } + try { + target.runtime?.bridge.publishWorkspaceEvent({ + type: 'settings_changed', + data: { key, value, scope }, + }); + } catch { + // Durable configuration is already committed. + } +} + +export function registerWorkspaceMcpConfigRoutes( + app: Application, + deps: McpConfigRoutesDeps, +): void { + registerFor( + app, + '/workspace', + 'user', + () => ({ workspaceCwd: deps.workspaceRegistry.primaryEntry.workspaceCwd }), + () => ({ workspaceCwd: deps.workspaceRegistry.primaryEntry.workspaceCwd }), + deps, + ); + registerFor( + app, + '/workspaces/:workspace', + 'workspace', + (req, res) => { + const entry = resolveWorkspaceEntryFromParam( + deps.workspaceRegistry, + req, + res, + ); + return entry ? { workspaceCwd: entry.workspaceCwd } : null; + }, + (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + return runtime && requireTrustedWorkspaceRuntime(runtime, res) + ? { workspaceCwd: runtime.workspaceCwd, runtime } + : null; + }, + deps, + ); +} diff --git a/packages/cli/src/serve/routes/workspace-runtime-mcp.ts b/packages/cli/src/serve/routes/workspace-runtime-mcp.ts new file mode 100644 index 00000000000..481cb06a46a --- /dev/null +++ b/packages/cli/src/serve/routes/workspace-runtime-mcp.ts @@ -0,0 +1,253 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application, Request, RequestHandler, Response } from 'express'; +import type { SendBridgeError } from '../server/error-response.js'; +import { + createBuildWorkspaceCtx, + validateMcpRuntimeServerName, +} from '../server/request-helpers.js'; +import { + requireTrustedWorkspaceRuntime, + resolveWorkspaceRuntimeFromParam, + sendWorkspaceRuntimeUnavailable, +} from '../workspace-route-runtime.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from '../workspace-registry.js'; +import { getWorkspaceRuntimeCoordinator } from '../workspace-runtime-coordinator.js'; + +interface WorkspaceRuntimeMcpRoutesDeps { + workspaceRegistry: WorkspaceRegistry; + mutate: (opts?: { strict?: boolean }) => RequestHandler; + safeBody: (req: Request) => Record; + sendBridgeError: SendBridgeError; +} + +type ResolveRuntime = (req: Request, res: Response) => WorkspaceRuntime | null; + +function parseReloadOptions(body: Record, res: Response) { + const forceReconnectAll = body['forceReconnectAll']; + const forceReconnectWhich = body['forceReconnectWhich']; + if ( + forceReconnectAll !== undefined && + typeof forceReconnectAll !== 'boolean' + ) { + res.status(400).json({ + error: '`forceReconnectAll` must be a boolean', + code: 'invalid_force_reconnect_all_flag', + }); + return null; + } + if ( + forceReconnectWhich !== undefined && + (!Array.isArray(forceReconnectWhich) || + forceReconnectWhich.some( + (name) => typeof name !== 'string' || name.length === 0, + )) + ) { + res.status(400).json({ + error: '`forceReconnectWhich` must be an array of server names', + code: 'invalid_force_reconnect_which', + }); + return null; + } + if (forceReconnectAll === true && forceReconnectWhich !== undefined) { + res.status(400).json({ + error: 'Reconnect options cannot be combined', + code: 'conflicting_force_reconnect_options', + }); + return null; + } + return { + ...(forceReconnectAll === undefined ? {} : { forceReconnectAll }), + ...(forceReconnectWhich === undefined + ? {} + : { forceReconnectWhich: forceReconnectWhich as string[] }), + }; +} + +function parseEntryIndex( + req: Request, + res: Response, +): number | undefined | null { + const raw = req.query['entryIndex']; + if (raw === undefined || raw === '*') return undefined; + const value = typeof raw === 'string' ? Number.parseInt(raw, 10) : NaN; + if (!Number.isInteger(value) || value < 0 || String(value) !== raw) { + res.status(400).json({ + error: '`entryIndex` must be a non-negative integer or "*"', + code: 'invalid_entry_index', + }); + return null; + } + return value; +} + +function registerFor( + app: Application, + base: string, + resolveRuntime: ResolveRuntime, + deps: WorkspaceRuntimeMcpRoutesDeps, +): void { + app.get(`${base}/runtime/mcp`, async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const route = `GET ${base}/runtime/mcp`; + try { + res + .status(200) + .json( + await runtime.workspaceService.getWorkspaceMcpStatus( + createBuildWorkspaceCtx(runtime.workspaceCwd)(route), + ), + ); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }); + + app.get(`${base}/runtime/mcp/:server/tools`, async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const serverName = req.params['server']; + if (!validateMcpRuntimeServerName(serverName, res)) return; + const route = `GET ${base}/runtime/mcp/:server/tools`; + try { + res + .status(200) + .json(await runtime.bridge.getWorkspaceMcpToolsStatus(serverName)); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }); + + app.get(`${base}/runtime/mcp/:server/resources`, async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const serverName = req.params['server']; + if (!validateMcpRuntimeServerName(serverName, res)) return; + const route = `GET ${base}/runtime/mcp/:server/resources`; + try { + res + .status(200) + .json(await runtime.bridge.getWorkspaceMcpResourcesStatus(serverName)); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }); + + app.post( + `${base}/runtime/mcp/reload`, + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const options = parseReloadOptions(deps.safeBody(req), res); + if (!options) return; + const route = `POST ${base}/runtime/mcp/reload`; + try { + const result = await getWorkspaceRuntimeCoordinator( + runtime, + ).runMcpRuntimeMutation(() => + runtime.bridge.reloadWorkspaceMcp(options), + ); + res.status(200).json(result); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }, + ); + + app.post( + `${base}/runtime/mcp/:server/restart`, + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const serverName = req.params['server']; + if (!validateMcpRuntimeServerName(serverName, res)) return; + const entryIndex = parseEntryIndex(req, res); + if (entryIndex === null) return; + const route = `POST ${base}/runtime/mcp/:server/restart`; + try { + const result = await getWorkspaceRuntimeCoordinator( + runtime, + ).runMcpRuntimeMutation(() => + runtime.workspaceService.restartMcpServer( + createBuildWorkspaceCtx(runtime.workspaceCwd)(route), + serverName, + entryIndex === undefined ? undefined : { entryIndex }, + ), + ); + res.status(200).json(result); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }, + ); + + for (const action of ['approve', 'authenticate', 'clear-auth'] as const) { + app.post( + `${base}/runtime/mcp/:server/${action}`, + deps.mutate({ strict: true }), + async (req, res) => { + const runtime = resolveRuntime(req, res); + if (!runtime) return; + const serverName = req.params['server']; + if (!validateMcpRuntimeServerName(serverName, res)) return; + const route = `POST ${base}/runtime/mcp/:server/${action}`; + try { + const result = await getWorkspaceRuntimeCoordinator( + runtime, + ).runMcpRuntimeMutation(() => + runtime.bridge.manageMcpServer(serverName, action, undefined), + ); + res.status(200).json(result); + } catch (error) { + deps.sendBridgeError(res, error, { route }); + } + }, + ); + } +} + +export function registerWorkspaceRuntimeMcpRoutes( + app: Application, + deps: WorkspaceRuntimeMcpRoutesDeps, +): void { + registerFor( + app, + '/workspace', + (_req, res) => { + const entry = deps.workspaceRegistry.primaryEntry; + const runtime = + entry.state === 'active' ? entry.current?.runtime : undefined; + if (!runtime) { + sendWorkspaceRuntimeUnavailable(res, entry); + return null; + } + return requireTrustedWorkspaceRuntime(runtime, res) ? runtime : null; + }, + deps, + ); + registerFor( + app, + '/workspaces/:workspace', + (req, res) => { + const runtime = resolveWorkspaceRuntimeFromParam( + deps.workspaceRegistry, + req, + res, + ); + return runtime && requireTrustedWorkspaceRuntime(runtime, res) + ? runtime + : null; + }, + deps, + ); +} diff --git a/packages/cli/src/serve/routes/workspace-settings.ts b/packages/cli/src/serve/routes/workspace-settings.ts index 096b5e6979f..35f44082d08 100644 --- a/packages/cli/src/serve/routes/workspace-settings.ts +++ b/packages/cli/src/serve/routes/workspace-settings.ts @@ -71,7 +71,7 @@ const VALID_WRITE_SCOPES = new Set(['workspace', 'user']); const QUALIFIED_WRITE_SCOPES = new Set(['workspace']); const mcpServerMutationQueues = new Map>(); -interface McpServerSettingMutation { +export interface McpServerSettingMutation { operation: 'set' | 'remove'; name: string; } @@ -223,7 +223,7 @@ const SCOPE_MAP: Record = { workspace: SettingScope.Workspace, }; -function prepareSettingWrite( +export function prepareSettingWrite( workspace: string, scope: SettingScope, key: string, @@ -277,7 +277,7 @@ function parseMcpServerMutation( return { operation, name }; } -async function withMcpServerMutationLock( +export async function withMcpServerMutationLock( workspace: string, scope: SettingScope, operation: () => Promise, diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index e79124fa307..5c57509864d 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -4170,6 +4170,32 @@ describe('runQwenServe telemetry validation', () => { expect(primaryEpochSource).toBeDefined(); expect(secondaryEpochSource).toBeDefined(); expect(primaryEpochSource).not.toBe(secondaryEpochSource); + const primaryMcpAuthenticationAdmission = createBridge.mock.calls.find( + ([options]) => options.boundWorkspace === canonicalizeWorkspace(primary), + )?.[0].acquireMcpAuthentication; + const secondaryMcpAuthenticationAdmission = createBridge.mock.calls.find( + ([options]) => + options.boundWorkspace === canonicalizeWorkspace(secondary), + )?.[0].acquireMcpAuthentication; + expect(primaryMcpAuthenticationAdmission).toBeDefined(); + expect(secondaryMcpAuthenticationAdmission).toBe( + primaryMcpAuthenticationAdmission, + ); + const releaseAuthentication = primaryMcpAuthenticationAdmission!( + primary, + 'aone', + ); + expect(releaseAuthentication).toBeTypeOf('function'); + expect(secondaryMcpAuthenticationAdmission!(secondary, 'yuque')).toBe( + undefined, + ); + releaseAuthentication!(); + const releaseSecondaryAuthentication = secondaryMcpAuthenticationAdmission!( + secondary, + 'yuque', + ); + expect(releaseSecondaryAuthentication).toBeTypeOf('function'); + releaseSecondaryAuthentication!(); for (const [options] of createBridge.mock.calls) { expect(options).toMatchObject({ delegateReadTextFileToClient: false, diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 5564d2584bb..41a0012f6e1 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -5115,6 +5115,17 @@ async function runQwenServeImpl( } return source; }; + let mcpAuthenticationActive = false; + const acquireMcpAuthentication = () => { + if (mcpAuthenticationActive) return undefined; + mcpAuthenticationActive = true; + let released = false; + return () => { + if (released) return; + released = true; + mcpAuthenticationActive = false; + }; + }; const totalSessionAdmission = runtime.createTotalSessionAdmissionController( { maxTotalSessions: opts.maxTotalSessions, @@ -5508,6 +5519,7 @@ async function runQwenServeImpl( : {}), boundWorkspace, runtimeEpochSource: runtimeEpochSourceFor(boundWorkspace), + acquireMcpAuthentication, // Prompt terminal ledger: persisted beside the transcript so a // restarted daemon can reconcile dangling prompts on cold load. promptLedger: runtime.createPromptLedgerSink( @@ -6077,6 +6089,7 @@ async function runQwenServeImpl( : {}), boundWorkspace: workspaceInput.cwd, runtimeEpochSource: runtimeEpochSourceFor(workspaceInput.cwd), + acquireMcpAuthentication, promptLedger: runtime.createPromptLedgerSink( workspaceInput.cwd, secondaryEnv.sessionRuntimeBaseDir, @@ -6751,6 +6764,7 @@ async function runQwenServeImpl( : {}), boundWorkspace: cwd, runtimeEpochSource: runtimeEpochSourceFor(cwd), + acquireMcpAuthentication, // Live-conversation workspaces keep transcripts outside the // runtime storage layout, so no ledger sink is wired there. ...(provenance === 'live-conversation' diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index d0f0e5ba67e..5bc87a13d3c 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -280,6 +280,8 @@ import { registerWorkspaceMcpControlRoutes, registerWorkspaceQualifiedMcpControlRoutes, } from './routes/workspace-mcp-control.js'; +import { registerWorkspaceMcpConfigRoutes } from './routes/workspace-mcp-config.js'; +import { registerWorkspaceRuntimeMcpRoutes } from './routes/workspace-runtime-mcp.js'; import { registerWorkspaceChannelControlRoutes } from './routes/workspace-channel-control.js'; import { registerWorkspaceChannelManagementRoutes } from './routes/workspace-channel-management.js'; import { registerWorkspaceChannelObservedContactRoutes } from './routes/workspace-channel-observed-contacts.js'; @@ -2642,6 +2644,17 @@ export function createServeApp( ...workspaceRegistry.list().map((runtime) => runtime.bridge), ]), }); + registerWorkspaceMcpConfigRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + persistSetting: async (...args) => { + await persistSetting(...args); + }, + sendBridgeError, + invalidateServeFeaturesCache, + broadcastSettingsChanged, + }); } registerWorkspacePermissionsRoutes(app, { boundWorkspace: primaryBoundWorkspace, @@ -2800,6 +2813,12 @@ export function createServeApp( safeBody, sendBridgeError, }); + registerWorkspaceRuntimeMcpRoutes(app, { + workspaceRegistry, + mutate, + safeBody, + sendBridgeError, + }); const channelWorkerControl = deps.getChannelWorkerControl ?? (deps.getChannelWorkerSnapshot diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index fb101357431..8c9882c4b1d 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -6,7 +6,10 @@ import type { Response } from 'express'; import { describe, expect, it, vi } from 'vitest'; -import { SessionNotFoundError } from '@qwen-code/acp-bridge/bridgeErrors'; +import { + McpAuthenticationInProgressError, + SessionNotFoundError, +} from '@qwen-code/acp-bridge/bridgeErrors'; import { SessionIdCaseConflictError, SessionTranscriptChangedError, @@ -45,6 +48,18 @@ function responseMock(): { } describe('sendBridgeError session writer errors', () => { + it('maps concurrent MCP authentication to conflict', () => { + const { response, status, json } = responseMock(); + + sendBridgeError(response, new McpAuthenticationInProgressError()); + + expect(status).toHaveBeenCalledWith(409); + expect(json).toHaveBeenCalledWith({ + error: 'Another MCP authentication is already in progress', + code: 'mcp_authentication_in_progress', + }); + }); + it('serializes the structured session-closing code', () => { const { response, status, json } = responseMock(); diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index f3e8f4b8398..a3264fb2ae2 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -30,6 +30,7 @@ import { InvalidRewindTargetError, InvalidSessionMetadataError, InvalidSessionScopeError, + McpAuthenticationInProgressError, McpServerNotFoundError, McpServerRestartFailedError, PermissionForbiddenError, @@ -517,6 +518,13 @@ export function sendBridgeError( }); return; } + if (err instanceof McpAuthenticationInProgressError) { + res.status(409).json({ + error: err.message, + code: 'mcp_authentication_in_progress', + }); + return; + } if (err instanceof McpServerNotFoundError) { // Stable 404 for "MCP server name not in config". res.status(404).json({ diff --git a/packages/cli/src/serve/workspace-qualified-rest.test.ts b/packages/cli/src/serve/workspace-qualified-rest.test.ts index a86d9ec1c8b..6d651da8a51 100644 --- a/packages/cli/src/serve/workspace-qualified-rest.test.ts +++ b/packages/cli/src/serve/workspace-qualified-rest.test.ts @@ -83,6 +83,15 @@ function makeBridge(): AcpSessionBridge { v: 1, resources: [], })), + getWorkspaceRuntimeLifecycleSnapshot: vi.fn(() => ({ + state: 'idle', + runtimeLive: true, + runtimeEpoch: 1, + activeWork: false, + })), + preheat: vi.fn(async () => {}), + initializeWorkspaceMcp: vi.fn(async () => ({ accepted: true })), + reloadWorkspaceMcp: vi.fn(async () => ({ accepted: true })), getDaemonStatusSnapshot: vi.fn(() => ({ limits: { maxSessions: 20, @@ -184,6 +193,10 @@ function makeWorkspaceService(label: string): DaemonWorkspaceService { getWorkspaceMcpStatus: vi.fn(async (ctx) => ({ v: 1, workspaceCwd: ctx.workspaceCwd, + initialized: true, + runtimeEpoch: 1, + source: 'live' as const, + discoveryState: 'completed' as const, servers: [{ name: label }], })), getWorkspaceSkillsStatus: vi.fn(async (ctx) => ({ @@ -215,6 +228,7 @@ function makeWorkspaceService(label: string): DaemonWorkspaceService { } async function makeHarness(opts?: { + primaryTrusted?: boolean; secondaryTrusted?: boolean; secondaryDirName?: string; token?: string; @@ -235,7 +249,7 @@ async function makeHarness(opts?: { const primaryFsFactoryBase = createWorkspaceFileSystemFactory({ boundWorkspaces: [primaryCwd], - trusted: true, + trusted: opts?.primaryTrusted ?? true, emit: () => {}, }); // A Proxy (not a spread) so prototype methods like resolve/stat are @@ -285,7 +299,7 @@ async function makeHarness(opts?: { workspaceCwd: primaryCwd, sessionRuntimeBaseDir: path.join(primaryCwd, '.runtime'), primary: true, - trusted: true, + trusted: opts?.primaryTrusted ?? true, env: { mode: 'parent-process', overlayKeys: [] }, bridge: makeBridge(), workspaceService: primaryWorkspaceService, @@ -566,6 +580,7 @@ describe('workspace-qualified core REST', () => { for (const route of [ 'mcp', + 'runtime/mcp', 'skills', 'providers', 'env', @@ -628,6 +643,16 @@ describe('workspace-qualified core REST', () => { .set('Host', host()); expect(mcpResources.status).toBe(200); expect(mcpResources.body).toEqual({ v: 1, resources: [] }); + + const runtimeMcp = await request(h.app) + .get(`/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp`) + .set('Host', host()); + expect(runtimeMcp.status).toBe(200); + expect(runtimeMcp.body).toMatchObject({ + workspaceCwd: h.secondaryCwd, + runtimeEpoch: 1, + source: 'live', + }); } finally { await fsp.rm(h.scratch, { recursive: true, force: true }); } @@ -1080,6 +1105,19 @@ describe('workspace-qualified core REST', () => { restarted: true, }); + const runtimeRestart = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp/docs/restart`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + expect(runtimeRestart.status).toBe(200); + expect(runtimeRestart.body).toMatchObject({ + serverName: 'docs', + restarted: true, + }); + const enable = await request(h.app) .post( `/workspaces/${encodeURIComponent(h.secondaryId)}/mcp/docs/enable`, @@ -1136,6 +1174,163 @@ describe('workspace-qualified core REST', () => { } }); + it('persists workspace MCP configuration through the selected workspace route', async () => { + const h = await makeHarness({ token: 'secret' }); + vi.mocked(h.secondaryBridge.publishWorkspaceEvent).mockImplementationOnce( + () => { + throw new Error('subscriber unavailable'); + }, + ); + try { + const response = await request(h.app) + .put( + `/workspaces/${encodeURIComponent(h.secondaryId)}/config/mcp/servers/docs`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ + scope: 'workspace', + config: { command: 'node', args: ['server.js'] }, + }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + name: 'docs', + scope: 'workspace', + activation: 'reconciling', + }); + expect(h.persistSetting).toHaveBeenCalledWith( + h.secondaryCwd, + expect.any(String), + 'mcpServers', + { docs: { command: 'node', args: ['server.js'] } }, + expect.any(Function), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('does not publish unchanged workspace MCP exclusions', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const response = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/config/mcp/docs/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'workspace' }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ changed: false }); + expect(h.secondaryBridge.publishWorkspaceEvent).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('rejects enabling an MCP server blocked by a wildcard exclusion', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + await fsp.mkdir(path.join(h.secondaryCwd, '.qwen'), { recursive: true }); + await fsp.writeFile( + path.join(h.secondaryCwd, '.qwen', 'settings.json'), + JSON.stringify({ mcp: { excluded: ['docs*'] } }), + ); + + const response = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/config/mcp/docs/enable`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'workspace' }); + + expect(response.status).toBe(409); + expect(response.body).toMatchObject({ + code: 'mcp_excluded_by_pattern', + patterns: ['docs*'], + }); + expect(h.persistSetting).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('reconciles user MCP configuration across managed workspaces', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const response = await request(h.app) + .put('/workspace/config/mcp/servers/global-docs') + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'user', config: { command: 'node' } }); + + expect(response.status).toBe(200); + expect(response.body.activation).toBe('reconciling'); + expect(h.primaryBridge.publishWorkspaceEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'settings_changed' }), + ); + expect(h.secondaryBridge.publishWorkspaceEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'settings_changed' }), + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('persists user MCP configuration without a live trusted primary runtime', async () => { + const h = await makeHarness({ primaryTrusted: false, token: 'secret' }); + h.workspaceRegistry.beginReplacement( + h.workspaceRegistry.primaryEntry, + 'policy-2', + ); + try { + const response = await request(h.app) + .put('/workspace/config/mcp/servers/global-docs') + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'user', config: { command: 'node' } }); + + expect(h.persistSetting).toHaveBeenCalledWith( + h.primaryCwd, + expect.any(String), + 'mcpServers', + expect.objectContaining({ + 'global-docs': expect.objectContaining({ command: 'node' }), + }), + undefined, + ); + expect(response.status).toBe(200); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('reads untrusted workspace MCP configuration but rejects its mutation', async () => { + const h = await makeHarness({ secondaryTrusted: false, token: 'secret' }); + try { + const path = `/workspaces/${encodeURIComponent(h.secondaryId)}/config/mcp/servers`; + const read = await request(h.app) + .get(path) + .set('Authorization', 'Bearer secret') + .set('Host', host()); + expect(read.status).toBe(200); + expect(read.body).toMatchObject({ v: 1 }); + + const mutation = await request(h.app) + .put(`${path}/docs`) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({ scope: 'workspace', config: { command: 'node' } }); + expect(mutation.status).toBe(403); + expect(mutation.body.code).toBe('untrusted_workspace'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + it('routes workspace-qualified tool toggles and validates mutation bodies', async () => { const h = await makeHarness({ token: 'secret' }); try { diff --git a/packages/cli/src/serve/workspace-runtime-coordinator.test.ts b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts index a1fa3967780..f26f691ee46 100644 --- a/packages/cli/src/serve/workspace-runtime-coordinator.test.ts +++ b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts @@ -33,19 +33,46 @@ function makeRuntime() { activeWork: false, }; }); + const initializeWorkspaceMcp = vi.fn(async () => ({ accepted: true })); + const reloadWorkspaceMcp = vi.fn(async () => ({ accepted: true })); + const getWorkspaceMcpStatus = vi.fn( + async (): Promise<{ + v: 1; + workspaceCwd: string; + initialized: boolean; + runtimeEpoch: number; + source: 'live' | 'cache'; + discoveryState: 'not_started' | 'in_progress' | 'completed'; + servers: []; + }> => ({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: snapshot.runtimeEpoch, + source: 'live', + discoveryState: 'completed', + servers: [], + }), + ); const bridge = { sessionCount: 0, preheat, + initializeWorkspaceMcp, + reloadWorkspaceMcp, getWorkspaceRuntimeLifecycleSnapshot: () => snapshot, } as unknown as AcpSessionBridge; const runtime = { workspaceCwd: '/workspace', bridge, + workspaceService: { getWorkspaceMcpStatus }, } as unknown as WorkspaceRuntime; return { runtime, bridge, preheat, + getWorkspaceMcpStatus, + initializeWorkspaceMcp, + reloadWorkspaceMcp, setSnapshot( update: Partial, ): void { @@ -70,12 +97,301 @@ describe('WorkspaceRuntimeCoordinator', () => { state: 'idle', runtimeLive: true, runtimeEpoch: 1, + capabilities: { + mcp: { state: 'ready', revision: 0, runtimeEpoch: 1 }, + }, }); expect(harness.preheat).toHaveBeenCalledWith({ keepAliveMs: 600_000, }); }); + it('marks MCP stale when its runtime stops without changing epoch', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + + harness.setSnapshot({ state: 'cold', runtimeLive: false }); + + expect(coordinator.status()).toMatchObject({ + state: 'cold', + runtimeLive: false, + runtimeEpoch: 1, + capabilities: { + mcp: { state: 'stale', revision: 0, runtimeEpoch: 1 }, + }, + }); + }); + + it('does not project queued MCP work into the runtime lifecycle', async () => { + const harness = makeRuntime(); + let resolveStatus!: (value: { + v: 1; + workspaceCwd: string; + initialized: boolean; + runtimeEpoch: number; + source: 'live'; + discoveryState: 'completed'; + servers: []; + }) => void; + harness.getWorkspaceMcpStatus.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStatus = resolve; + }), + ); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + const ensure = coordinator.ensure(); + await vi.waitFor(() => { + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledOnce(); + }); + + expect(coordinator.status()).toMatchObject({ + state: 'idle', + capabilities: { mcp: { state: 'starting' } }, + }); + + resolveStatus({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: 1, + source: 'live', + discoveryState: 'completed', + servers: [], + }); + await ensure; + }); + + it('defers MCP configuration reconciliation while cold', () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + + expect(coordinator.reconcileMcpConfiguration()).toBe('deferred'); + expect(coordinator.status().capabilities?.mcp?.state).toBe('not_started'); + expect(harness.reloadWorkspaceMcp).not.toHaveBeenCalled(); + }); + + it('reconciles MCP configuration on the live workspace runtime', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + + expect(coordinator.reconcileMcpConfiguration()).toBe('reconciling'); + await vi.waitFor(() => { + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + runtimeEpoch: 1, + }); + }); + }); + + it('observes an MCP reload already in progress', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + harness.reloadWorkspaceMcp.mockResolvedValueOnce({ accepted: false }); + + expect(coordinator.reconcileMcpConfiguration()).toBe('reconciling'); + + await vi.waitFor(() => { + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + }); + }); + }); + + it('skips superseded queued MCP reloads', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + + coordinator.reconcileMcpConfiguration(); + coordinator.reconcileMcpConfiguration(); + + await vi.waitFor(() => { + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 2, + }); + }); + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + }); + + it('records a background MCP reconciliation failure', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + harness.reloadWorkspaceMcp.mockRejectedValueOnce( + new Error('reload failed'), + ); + + expect(coordinator.reconcileMcpConfiguration()).toBe('reconciling'); + + await vi.waitFor(() => { + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'error', + revision: 1, + error: { message: 'reload failed' }, + }); + }); + }); + + it('preheats a cold runtime before an MCP mutation', async () => { + const harness = makeRuntime(); + const mutation = vi.fn(async () => ({ accepted: true })); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + + await expect(coordinator.runMcpRuntimeMutation(mutation)).resolves.toEqual({ + accepted: true, + }); + + expect(harness.preheat).toHaveBeenCalledOnce(); + expect(mutation).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + runtimeEpoch: 1, + }); + }); + + it('rechecks MCP readiness after a rejected runtime mutation', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + const error = new Error('restart failed'); + + await expect( + coordinator.runMcpRuntimeMutation(async () => { + throw error; + }), + ).rejects.toBe(error); + + await vi.waitFor(() => { + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + runtimeEpoch: 1, + }); + }); + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledOnce(); + }); + + it('waits for live MCP status from the current runtime epoch', async () => { + const harness = makeRuntime(); + harness.getWorkspaceMcpStatus.mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: 0, + source: 'cache', + discoveryState: 'completed', + servers: [], + }); + + const result = await getWorkspaceRuntimeCoordinator( + harness.runtime, + ).ensure(); + + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledTimes(2); + expect(result.capabilities?.mcp).toMatchObject({ + state: 'ready', + runtimeEpoch: 1, + }); + }); + + it('waits for the latest MCP revision when ensure overlaps a config change', async () => { + const harness = makeRuntime(); + let releaseFirstStatus: (() => void) | undefined; + const firstStatus = new Promise((resolve) => { + releaseFirstStatus = resolve; + }); + harness.getWorkspaceMcpStatus.mockImplementationOnce(async () => { + await firstStatus; + return { + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: 1, + source: 'live', + discoveryState: 'completed', + servers: [], + }; + }); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + const firstEnsure = coordinator.ensure(); + await vi.waitFor(() => { + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledOnce(); + }); + harness.preheat.mockImplementation(async () => {}); + + expect(coordinator.reconcileMcpConfiguration()).toBe('reconciling'); + const latestEnsure = coordinator.ensure(); + releaseFirstStatus?.(); + + await firstEnsure; + await expect(latestEnsure).resolves.toMatchObject({ + capabilities: { mcp: { state: 'ready', revision: 1 } }, + }); + }); + + it('abandons stale MCP preparation before running the next revision', async () => { + const harness = makeRuntime(); + let releaseFirstStatus: (() => void) | undefined; + const firstStatus = new Promise((resolve) => { + releaseFirstStatus = resolve; + }); + harness.getWorkspaceMcpStatus.mockImplementationOnce(async () => { + await firstStatus; + return { + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: 1, + source: 'live', + discoveryState: 'in_progress', + servers: [], + }; + }); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + const firstEnsure = coordinator.ensure(); + await vi.waitFor(() => { + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledOnce(); + }); + + expect(coordinator.reconcileMcpConfiguration()).toBe('reconciling'); + releaseFirstStatus?.(); + + await firstEnsure; + await vi.waitFor(() => { + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + }); + }); + }); + + it('replays a live MCP reconciliation after drain rollback', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + coordinator.beginDrain(); + + expect(coordinator.reconcileMcpConfiguration()).toBe('deferred'); + coordinator.cancelDrain(); + + await vi.waitFor(() => { + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 1, + }); + }); + }); + it('renews the warm window on every ensure call', async () => { const harness = makeRuntime(); harness.setSnapshot({ diff --git a/packages/cli/src/serve/workspace-runtime-coordinator.ts b/packages/cli/src/serve/workspace-runtime-coordinator.ts index 912a427c0ab..d68916ba45a 100644 --- a/packages/cli/src/serve/workspace-runtime-coordinator.ts +++ b/packages/cli/src/serve/workspace-runtime-coordinator.ts @@ -6,6 +6,7 @@ import { STATUS_SCHEMA_VERSION, + type ServeWorkspaceRuntimeCapabilityStatus, type ServeWorkspaceRuntimeStatus, } from '@qwen-code/acp-bridge/status'; import type { @@ -17,6 +18,8 @@ import type { WorkspaceRuntime } from './workspace-registry.js'; const DEFAULT_ENSURE_TIMEOUT_MS = 60_000; const ENSURE_KEEP_ALIVE_MS = 10 * 60_000; +const MCP_PREPARE_TIMEOUT_MS = 2 * 60_000; +const MCP_POLL_INTERVAL_MS = 250; type LifecycleAcpSessionBridge = AcpSessionBridge & { getWorkspaceRuntimeLifecycleSnapshot(): BridgeWorkspaceRuntimeLifecycleSnapshot; @@ -63,6 +66,21 @@ export class WorkspaceRuntimeCoordinator { private draining = false; + private activeManagementOperations = 0; + + private mcpRevision = 0; + + private mcpStatus: ServeWorkspaceRuntimeCapabilityStatus = { + state: 'not_started', + revision: 0, + }; + + private mcpReconcileDeferred = false; + + private mcpPhysicalTail: Promise = Promise.resolve(); + + private mcpQueuedWork = 0; + constructor( private readonly runtime: WorkspaceRuntime, private readonly bridge: LifecycleAcpSessionBridge, @@ -73,11 +91,20 @@ export class WorkspaceRuntimeCoordinator { } cancelDrain(): void { - if (!this.disposed) this.draining = false; + if (this.disposed) return; + this.draining = false; + if (this.mcpReconcileDeferred) { + this.mcpReconcileDeferred = false; + this.scheduleMcpReconciliation(); + } } hasActiveWork(): boolean { - return this.bridge.getWorkspaceRuntimeLifecycleSnapshot().activeWork; + return ( + this.activeManagementOperations > 0 || + this.mcpQueuedWork > 0 || + this.bridge.getWorkspaceRuntimeLifecycleSnapshot().activeWork + ); } dispose(): void { @@ -87,12 +114,23 @@ export class WorkspaceRuntimeCoordinator { status(): ServeWorkspaceRuntimeStatus { const snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + const mcpStatus = + this.mcpStatus.runtimeEpoch !== undefined && + (!snapshot.runtimeLive || + this.mcpStatus.runtimeEpoch !== snapshot.runtimeEpoch) + ? { + state: 'stale' as const, + revision: this.mcpStatus.revision, + runtimeEpoch: this.mcpStatus.runtimeEpoch, + } + : this.mcpStatus; return { v: STATUS_SCHEMA_VERSION, workspaceCwd: this.runtime.workspaceCwd, state: snapshot.state, runtimeLive: snapshot.runtimeLive, runtimeEpoch: snapshot.runtimeEpoch, + capabilities: { mcp: mcpStatus }, }; } @@ -100,6 +138,7 @@ export class WorkspaceRuntimeCoordinator { timeoutMs = DEFAULT_ENSURE_TIMEOUT_MS, ): Promise { this.assertAcceptingWork(); + const deadline = Date.now() + timeoutMs; try { await withTimeout( this.bridge.preheat({ keepAliveMs: ENSURE_KEEP_ALIVE_MS }), @@ -117,7 +156,238 @@ export class WorkspaceRuntimeCoordinator { new Error('ACP preheat completed without a live runtime'), ); } - return status; + const preparation = this.prepareMcp(); + const remainingMs = deadline - Date.now(); + if (remainingMs > 0) { + try { + await withTimeout(preparation, remainingMs); + } catch (error) { + if (!(error instanceof WorkspaceRuntimeStillStartingError)) throw error; + } + } + return this.status(); + } + + async runManagementOperation(run: () => Promise): Promise { + this.assertAcceptingWork(); + this.activeManagementOperations += 1; + try { + return await run(); + } finally { + this.activeManagementOperations -= 1; + } + } + + reconcileMcpConfiguration(): 'deferred' | 'reconciling' { + this.mcpRevision += 1; + return this.scheduleMcpReconciliation(); + } + + private scheduleMcpReconciliation(): 'deferred' | 'reconciling' { + const snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if (!snapshot.runtimeLive || this.draining || this.disposed) { + this.mcpReconcileDeferred ||= snapshot.runtimeLive && this.draining; + this.mcpStatus = { + state: + this.mcpStatus.runtimeEpoch === undefined ? 'not_started' : 'stale', + revision: this.mcpRevision, + ...(this.mcpStatus.runtimeEpoch === undefined + ? {} + : { runtimeEpoch: this.mcpStatus.runtimeEpoch }), + }; + return 'deferred'; + } + this.mcpStatus = { + state: 'starting', + revision: this.mcpRevision, + runtimeEpoch: snapshot.runtimeEpoch, + }; + const revision = this.mcpRevision; + void this.queueMcpWork(async () => { + if (revision !== this.mcpRevision) return; + await this.bridge.reloadWorkspaceMcp(); + await this.prepareMcpRevision(revision); + }).catch((error: unknown) => { + if (this.draining && !this.disposed) this.mcpReconcileDeferred = true; + this.recordMcpError(revision, snapshot.runtimeEpoch, error); + }); + return 'reconciling'; + } + + async runMcpRuntimeMutation(run: () => Promise): Promise { + this.assertAcceptingWork(); + const revision = ++this.mcpRevision; + const initial = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + this.mcpStatus = { + state: initial.runtimeLive ? 'starting' : 'not_started', + revision, + ...(initial.runtimeLive ? { runtimeEpoch: initial.runtimeEpoch } : {}), + }; + return await this.queueMcpWork(async () => { + let snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + let mutationRejected = false; + try { + if (!snapshot.runtimeLive) { + await this.bridge.preheat({ keepAliveMs: ENSURE_KEEP_ALIVE_MS }); + snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if (!snapshot.runtimeLive) { + throw new WorkspaceRuntimeInitializationError( + new Error('ACP preheat completed without a live runtime'), + ); + } + } + if (revision === this.mcpRevision) { + this.mcpStatus = { + state: 'starting', + revision, + runtimeEpoch: snapshot.runtimeEpoch, + }; + } + let result: T; + try { + result = await run(); + } catch (error) { + mutationRejected = true; + void this.queueMcpWork(() => this.prepareMcpRevision(revision)).catch( + (prepareError: unknown) => { + this.recordMcpError( + revision, + snapshot.runtimeEpoch, + prepareError, + ); + }, + ); + throw error; + } + await this.prepareMcpRevision(revision); + return result; + } catch (error) { + if (!mutationRejected) { + this.recordMcpError(revision, snapshot.runtimeEpoch, error); + } + throw error; + } + }); + } + + private prepareMcp(): Promise { + const revision = this.mcpRevision; + return this.queueMcpWork(() => this.prepareMcpRevision(revision)); + } + + private queueMcpWork(run: () => Promise): Promise { + this.mcpQueuedWork += 1; + const operation = this.mcpPhysicalTail + .catch(() => undefined) + .then(async () => { + this.assertAcceptingWork(); + return await run(); + }) + .finally(() => { + this.mcpQueuedWork -= 1; + }); + this.mcpPhysicalTail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + private async prepareMcpRevision(revision: number): Promise { + const deadline = Date.now() + MCP_PREPARE_TIMEOUT_MS; + let snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if (!snapshot.runtimeLive) { + await this.bridge.preheat({ keepAliveMs: ENSURE_KEEP_ALIVE_MS }); + snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + } + const epoch = snapshot.runtimeEpoch; + if (revision !== this.mcpRevision) return; + this.mcpStatus = { state: 'starting', revision, runtimeEpoch: epoch }; + try { + let status = await this.runtime.workspaceService.getWorkspaceMcpStatus({ + route: 'workspace runtime MCP preparation', + workspaceCwd: this.runtime.workspaceCwd, + }); + let initializationRequested = false; + while (true) { + if (revision !== this.mcpRevision) return; + const current = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if ( + this.draining || + this.disposed || + !current.runtimeLive || + current.runtimeEpoch !== epoch + ) { + this.mcpStatus = { state: 'stale', revision, runtimeEpoch: epoch }; + return; + } + const currentEpochStatus = + status.runtimeEpoch === epoch && status.source === 'live'; + if ( + currentEpochStatus && + status.discoveryState === 'not_started' && + !initializationRequested + ) { + initializationRequested = true; + await this.bridge.initializeWorkspaceMcp(); + } + if (currentEpochStatus && status.errors?.length) { + throw new Error( + status.errors[0]?.error ?? 'MCP discovery failed to initialize', + ); + } + if (currentEpochStatus && status.discoveryState === 'completed') break; + if (Date.now() >= deadline) { + throw new WorkspaceRuntimeStillStartingError(); + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, MCP_POLL_INTERVAL_MS); + timer.unref?.(); + }); + status = await this.runtime.workspaceService.getWorkspaceMcpStatus({ + route: 'workspace runtime MCP preparation', + workspaceCwd: this.runtime.workspaceCwd, + }); + } + const current = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if (revision !== this.mcpRevision) return; + if (!current.runtimeLive || current.runtimeEpoch !== epoch) { + this.mcpStatus = { state: 'stale', revision, runtimeEpoch: epoch }; + return; + } + this.mcpStatus = { state: 'ready', revision, runtimeEpoch: epoch }; + } catch (error) { + this.recordMcpError(revision, epoch, error); + } + } + + private recordMcpError( + revision: number, + runtimeEpoch: number, + error: unknown, + ): void { + const current = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); + if (revision !== this.mcpRevision) return; + if ( + this.draining || + !current.runtimeLive || + current.runtimeEpoch !== runtimeEpoch + ) { + this.mcpStatus = { state: 'stale', revision, runtimeEpoch }; + return; + } + this.mcpStatus = { + state: 'error', + revision, + runtimeEpoch, + error: { + code: + error instanceof WorkspaceRuntimeStillStartingError + ? 'mcp_prepare_timed_out' + : 'mcp_prepare_failed', + message: error instanceof Error ? error.message : String(error), + }, + }; } private assertAcceptingWork(cause?: unknown): void { diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 351d2279b17..d94bfdbcdcb 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -102,7 +102,10 @@ import type { DaemonGitHubPullRequestList, DaemonGitHubPullRequestCreateResult, DaemonWorkspaceMcpStatus, + DaemonWorkspaceMcpConfigStatus, + DaemonMcpConfigMutationResult, DaemonWorkspaceMcpInitializeResult, + DaemonWorkspaceMcpReloadResult, DaemonWorkspaceMcpReloadOptions, DaemonWorkspaceMcpToolsStatus, DaemonWorkspaceMcpResourcesStatus, @@ -1247,6 +1250,42 @@ export class DaemonClient { ); } + async setUserMcpServer( + name: string, + config: Record, + ): Promise { + return await this.jsonRequest( + `/workspace/config/mcp/servers/${urlEncode(name)}`, + 'PUT /workspace/config/mcp/servers/:name', + { + method: 'PUT', + body: { scope: 'user', config }, + mode: 'rest', + }, + ); + } + + async removeUserMcpServer( + name: string, + ): Promise { + return await this.jsonRequest( + `/workspace/config/mcp/servers/${urlEncode(name)}?scope=user`, + 'DELETE /workspace/config/mcp/servers/:name', + { method: 'DELETE', mode: 'rest' }, + ); + } + + async setUserMcpServerEnabled( + name: string, + enabled: boolean, + ): Promise { + return await this.jsonRequest( + `/workspace/config/mcp/${urlEncode(name)}/${enabled ? 'enable' : 'disable'}`, + 'POST /workspace/config/mcp/:server/:action', + { method: 'POST', body: {}, mode: 'rest' }, + ); + } + async initializeWorkspaceMcp(): Promise { return await this.fetchWithTimeout( `${this.baseUrl}/workspace/mcp/initialize`, @@ -5889,6 +5928,128 @@ export class WorkspaceDaemonClient { return this.get('/mcp', 'GET /workspaces/:workspace/mcp'); } + mcpConfig(): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/config/mcp/servers', + 'GET /workspaces/:workspace/config/mcp/servers', + { mode: 'rest' }, + ); + } + + setMcpServer( + name: string, + config: Record, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/config/mcp/servers/${urlEncode(name)}`, + 'PUT /workspaces/:workspace/config/mcp/servers/:name', + { + method: 'PUT', + body: { scope: 'workspace', config }, + mode: 'rest', + }, + ); + } + + removeMcpServer(name: string): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/config/mcp/servers/${urlEncode(name)}?scope=workspace`, + 'DELETE /workspaces/:workspace/config/mcp/servers/:name', + { method: 'DELETE', mode: 'rest' }, + ); + } + + setMcpServerEnabled( + name: string, + enabled: boolean, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/config/mcp/${urlEncode(name)}/${enabled ? 'enable' : 'disable'}`, + 'POST /workspaces/:workspace/config/mcp/:server/:action', + { method: 'POST', body: {}, mode: 'rest' }, + ); + } + + runtimeMcp(): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/runtime/mcp', + 'GET /workspaces/:workspace/runtime/mcp', + { mode: 'rest' }, + ); + } + + runtimeMcpTools(serverName: string): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/runtime/mcp/${urlEncode(serverName)}/tools`, + 'GET /workspaces/:workspace/runtime/mcp/:server/tools', + { mode: 'rest' }, + ); + } + + runtimeMcpResources( + serverName: string, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/runtime/mcp/${urlEncode(serverName)}/resources`, + 'GET /workspaces/:workspace/runtime/mcp/:server/resources', + { mode: 'rest' }, + ); + } + + reloadRuntimeMcp( + options: DaemonWorkspaceMcpReloadOptions = {}, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + '/runtime/mcp/reload', + 'POST /workspaces/:workspace/runtime/mcp/reload', + { + method: 'POST', + body: options, + mode: 'rest', + timeoutMs: MCP_RESTART_DEFAULT_TIMEOUT_MS, + }, + ); + } + + restartRuntimeMcpServer(serverName: string): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/runtime/mcp/${urlEncode(serverName)}/restart`, + 'POST /workspaces/:workspace/runtime/mcp/:server/restart', + { + method: 'POST', + body: {}, + mode: 'rest', + timeoutMs: MCP_RESTART_DEFAULT_TIMEOUT_MS, + }, + ); + } + + manageRuntimeMcpServer( + serverName: string, + action: 'approve' | 'authenticate' | 'clear-auth', + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/runtime/mcp/${urlEncode(serverName)}/${action}`, + 'POST /workspaces/:workspace/runtime/mcp/:server/:action', + { + method: 'POST', + body: {}, + mode: 'rest', + timeoutMs: MCP_RESTART_DEFAULT_TIMEOUT_MS, + }, + ); + } + ensureRuntime(): Promise { return this.client.workspaceJsonRequest( this.workspaceSelector, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 2cae041e317..b8ff26339b0 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -675,7 +675,11 @@ export type { DaemonWorkspaceFileWriteResult, DaemonWorkspaceMcpServerStatus, DaemonWorkspaceMcpStatus, + DaemonWorkspaceMcpConfigStatus, + DaemonMcpConfigMutationResult, + DaemonMcpConfigScope, DaemonWorkspaceMcpInitializeResult, + DaemonWorkspaceMcpReloadResult, DaemonWorkspaceMcpReloadOptions, DaemonWorkspaceMcpToolStatus, DaemonWorkspaceMcpToolsStatus, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 19a755599eb..ac1a0c0a8be 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1832,6 +1832,8 @@ export interface DaemonWorkspaceMcpStatus { v: 1; workspaceCwd: string; initialized: boolean; + runtimeEpoch?: number; + source?: 'live' | 'cache'; discoveryState?: DaemonMcpDiscoveryState; servers: DaemonWorkspaceMcpServerStatus[]; errors?: DaemonStatusCell[]; @@ -1849,12 +1851,36 @@ export interface DaemonWorkspaceMcpStatus { budgets?: DaemonMcpBudgetStatusCell[]; } +export type DaemonMcpConfigScope = 'user' | 'workspace'; + +export interface DaemonWorkspaceMcpConfigStatus { + v: 1; + effective: Record; + user: Record; + workspace: Record; +} + +export interface DaemonMcpConfigMutationResult { + name?: string; + serverName?: string; + scope?: DaemonMcpConfigScope; + config?: unknown; + action?: 'enable' | 'disable'; + ok?: true; + changed?: boolean; + activation: 'applied' | 'deferred' | 'reconciling'; +} + /** Response of `POST /workspace/mcp/initialize`. */ export interface DaemonWorkspaceMcpInitializeResult { /** True only when this request started a new background discovery task. */ accepted: boolean; } +export interface DaemonWorkspaceMcpReloadResult { + accepted: boolean; +} + export interface DaemonWorkspaceMcpReloadOptions { forceReconnectAll?: boolean; forceReconnectWhich?: string[]; @@ -1875,6 +1901,7 @@ export interface DaemonWorkspaceMcpToolsStatus { workspaceCwd: string; serverName: string; initialized: boolean; + runtimeEpoch?: number; acpChannelLive: boolean; tools: DaemonWorkspaceMcpToolStatus[]; errors?: DaemonStatusCell[]; @@ -1903,6 +1930,7 @@ export interface DaemonWorkspaceMcpResourcesStatus { workspaceCwd: string; serverName: string; initialized: boolean; + runtimeEpoch?: number; acpChannelLive: boolean; resources: DaemonWorkspaceMcpResourceStatus[]; errors?: DaemonStatusCell[]; @@ -1954,6 +1982,14 @@ export interface DaemonWorkspaceRuntimeStatus { state: 'cold' | 'starting' | 'active' | 'idle' | 'stopping'; runtimeLive: boolean; runtimeEpoch: number; + capabilities?: { + mcp?: { + state: 'not_started' | 'starting' | 'ready' | 'stale' | 'error'; + revision: number; + runtimeEpoch?: number; + error?: { code: string; message: string }; + }; + }; } export interface DaemonWorkspaceProviderCurrent { diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 872959a6f7e..169e5a21350 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -309,7 +309,11 @@ export { type DaemonFollowupSuggestionEvent, type DaemonWorkspaceMcpServerStatus, type DaemonWorkspaceMcpStatus, + type DaemonWorkspaceMcpConfigStatus, + type DaemonMcpConfigMutationResult, + type DaemonMcpConfigScope, type DaemonWorkspaceMcpInitializeResult, + type DaemonWorkspaceMcpReloadResult, type DaemonWorkspaceAcpStatusResult, type DaemonWorkspaceAcpPreheatResult, type DaemonWorkspaceRuntimeStatus, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 57c30a6415b..8a86fcdfcec 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -1267,6 +1267,92 @@ describe('DaemonClient', () => { ]); }); + it('uses the workspace-scoped MCP config and runtime REST routes', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { activation: 'reconciling' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const workspace = client.workspaceByCwd('/work/secondary'); + + await client.setUserMcpServer('docs', { command: 'node' }); + await client.setUserMcpServerEnabled('docs', false); + await client.removeUserMcpServer('docs'); + await workspace.setMcpServer('docs', { command: 'node' }); + await workspace.setMcpServerEnabled('docs', true); + await workspace.removeMcpServer('docs'); + await workspace.mcpConfig(); + await workspace.runtimeMcp(); + await workspace.runtimeMcpTools('docs'); + await workspace.runtimeMcpResources('docs'); + await workspace.reloadRuntimeMcp({ forceReconnectWhich: ['docs'] }); + await workspace.restartRuntimeMcpServer('docs'); + await workspace.manageRuntimeMcpServer('docs', 'authenticate'); + + expect(calls.map((call) => [call.method, call.url, call.body])).toEqual([ + [ + 'PUT', + 'http://daemon/workspace/config/mcp/servers/docs', + '{"scope":"user","config":{"command":"node"}}', + ], + ['POST', 'http://daemon/workspace/config/mcp/docs/disable', '{}'], + [ + 'DELETE', + 'http://daemon/workspace/config/mcp/servers/docs?scope=user', + null, + ], + [ + 'PUT', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/config/mcp/servers/docs', + '{"scope":"workspace","config":{"command":"node"}}', + ], + [ + 'POST', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/config/mcp/docs/enable', + '{}', + ], + [ + 'DELETE', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/config/mcp/servers/docs?scope=workspace', + null, + ], + [ + 'GET', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/config/mcp/servers', + null, + ], + [ + 'GET', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp', + null, + ], + [ + 'GET', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp/docs/tools', + null, + ], + [ + 'GET', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp/docs/resources', + null, + ], + [ + 'POST', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp/reload', + '{"forceReconnectWhich":["docs"]}', + ], + [ + 'POST', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp/docs/restart', + '{}', + ], + [ + 'POST', + 'http://daemon/workspaces/%2Fwork%2Fsecondary/runtime/mcp/docs/authenticate', + '{}', + ], + ]); + }); + it('reads primary and workspace-qualified Git status over REST', async () => { const primary = { v: 1 as const, diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index cceba103096..8b73b644a6d 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -19,6 +19,7 @@ import { type DaemonSessionTaskStatus, type DaemonSettingDescriptor, type DaemonSkillToggleMutation, + type DaemonWorkspaceMcpServerStatus, type DaemonWorkspaceGitStatus, type GoalSnapshotV2, } from '@qwen-code/sdk/daemon'; @@ -367,6 +368,14 @@ const { }), }, mockMcp: { + ensureRuntime: vi.fn().mockResolvedValue({}), + runtimeStatus: vi.fn().mockResolvedValue({ + runtimeLive: true, + runtimeEpoch: 1, + capabilities: { + mcp: { state: 'ready', revision: 0, runtimeEpoch: 1 }, + }, + }), initialize: vi.fn().mockResolvedValue({ accepted: true }), reloadConfig: vi.fn().mockResolvedValue({ accepted: true }), reload: vi.fn(), @@ -376,6 +385,9 @@ const { manageServer: vi.fn(), addServer: vi.fn(), removeServer: vi.fn(), + setConfigServer: vi.fn(), + removeConfigServer: vi.fn(), + setConfigServerEnabled: vi.fn(), loading: false, error: undefined, }, @@ -1916,6 +1928,10 @@ const { mergeMonitorTaskSnapshot, mergeSideTaskCatalog, } = await import('./App'); +const { McpStatusMessage } = await import( + './components/messages/McpStatusMessage' +); +const { I18nProvider } = await import('./i18n'); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -5492,6 +5508,16 @@ beforeEach(() => { mockWorkspaceActions.deleteModel.mockResolvedValue(undefined); mockMcp.initialize.mockClear(); mockMcp.initialize.mockResolvedValue({ accepted: true }); + mockMcp.ensureRuntime.mockClear(); + mockMcp.ensureRuntime.mockResolvedValue({}); + mockMcp.runtimeStatus.mockClear(); + mockMcp.runtimeStatus.mockResolvedValue({ + runtimeLive: true, + runtimeEpoch: 1, + capabilities: { + mcp: { state: 'ready', revision: 0, runtimeEpoch: 1 }, + }, + }); mockMcp.reloadConfig.mockClear(); mockMcp.reloadConfig.mockResolvedValue({ accepted: true }); mockMcp.reload.mockReset(); @@ -5504,6 +5530,9 @@ beforeEach(() => { durationMs: 1, }); mockMcp.manageServer.mockReset(); + mockMcp.setConfigServer.mockReset(); + mockMcp.removeConfigServer.mockReset(); + mockMcp.setConfigServerEnabled.mockReset(); vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -18295,8 +18324,12 @@ describe('App session callbacks', () => { }); it('restores composer interaction after closing Plugins on the MCP tab', async () => { - mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ + mockMcp.reload.mockResolvedValue({ + v: 1, + workspaceCwd: '/workspace', initialized: true, + runtimeEpoch: 1, + source: 'live', discoveryState: 'completed', servers: [], }); @@ -18329,7 +18362,7 @@ describe('App session callbacks', () => { await Promise.resolve(); }); await flush(); - expect(mockWorkspaceActions.loadMcpStatus).toHaveBeenCalled(); + expect(mockMcp.reload).toHaveBeenCalled(); expect(testState.latestChatEditorProps?.dialogOpen).toBe(true); const mcpPanel = container.querySelector('[data-testid="inline-panel"]'); @@ -18349,6 +18382,48 @@ describe('App session callbacks', () => { expect(testState.latestChatEditorProps?.dialogOpen).toBe(false); }); + it('ensures the MCP runtime when the manager is refreshed', async () => { + mockMcp.reload.mockResolvedValue({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: 1, + source: 'live', + discoveryState: 'completed', + servers: [], + }); + const { container } = renderApp(); + await flush(); + + await act(async () => { + container + .querySelector('[data-testid="open-plugins"]') + ?.click(); + await Promise.resolve(); + }); + const panel = container.querySelector('[data-testid="inline-panel"]'); + const mcpTab = + panel?.querySelectorAll('button[role="tab"]')[1]; + await act(async () => { + mcpTab?.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + mcpTab?.click(); + await Promise.resolve(); + }); + await vi.waitFor(() => expect(mockMcp.reload).toHaveBeenCalled()); + mockMcp.ensureRuntime.mockClear(); + + const refresh = Array.from( + panel?.querySelectorAll('button') ?? [], + ).find((button) => button.textContent === 'Refresh'); + await act(async () => refresh?.click()); + + await vi.waitFor(() => { + expect(mockMcp.ensureRuntime).toHaveBeenCalledOnce(); + }); + }); + it('opens Channel management from the sidebar', async () => { const { container } = renderApp(); await flush(); @@ -18521,11 +18596,116 @@ describe('App session callbacks', () => { ).toHaveProperty('tabIndex', 0); }); - it('shows server operations without duplicating tools and resources tabs', async () => { + it('prioritizes MCP approval and authentication labels over connection labels', async () => { + const servers: DaemonWorkspaceMcpServerStatus[] = [ + { + name: 'disabled-server', + configOrigin: 'workspace_settings', + disabled: true, + approvalState: 'pending', + requiresAuth: true, + mcpStatus: 'connected', + }, + { + name: 'approval', + configOrigin: 'workspace_settings', + disabled: false, + approvalState: 'pending', + requiresAuth: true, + mcpStatus: 'connected', + }, + { + name: 'authenticating', + configOrigin: 'workspace_settings', + disabled: false, + authenticationState: 'pending', + mcpStatus: 'connected', + }, + { + name: 'auth-failed', + configOrigin: 'workspace_settings', + disabled: false, + authenticationState: 'failed', + mcpStatus: 'connected', + }, + { + name: 'needs-auth', + configOrigin: 'workspace_settings', + disabled: false, + requiresAuth: true, + mcpStatus: 'connecting', + }, + { + name: 'connected-server', + configOrigin: 'workspace_settings', + disabled: false, + authenticationState: 'succeeded', + requiresAuth: true, + mcpStatus: 'connected', + }, + ]; mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ initialized: true, discoveryState: 'completed', workspaceCwd: '/workspace', + source: 'live', + runtimeEpoch: 1, + servers, + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/mcp'; + await clickSubmit(container); + await flush(); + + const labelFor = (name: string) => + container.querySelector(`[aria-label="${name}"]`)?.textContent; + expect(labelFor('disabled-server')).toContain('disabled'); + expect(labelFor('approval')).toContain('Needs approval'); + expect(labelFor('authenticating')).toContain('Authenticating'); + expect(labelFor('auth-failed')).toContain('Authentication failed'); + expect(labelFor('needs-auth')).toContain('Needs authentication'); + expect(labelFor('connected-server')).toContain('connected'); + + const messageContainer = document.createElement('div'); + document.body.appendChild(messageContainer); + const root = createRoot(messageContainer); + mounted.push({ root, container: messageContainer }); + await act(async () => { + root.render( + + + , + ); + }); + expect(messageContainer.textContent).toContain('Needs approval'); + expect(messageContainer.textContent).toContain('Authenticating'); + expect(messageContainer.textContent).toContain('Authentication failed'); + expect(messageContainer.textContent).toContain('Needs authentication'); + }); + + it('shows server operations without duplicating tools and resources tabs', async () => { + mockMcp.reload.mockResolvedValue({ + initialized: true, + discoveryState: 'completed', + workspaceCwd: '/workspace', + source: 'live', + runtimeEpoch: 1, servers: [ { name: 'filesystem', @@ -18591,6 +18771,222 @@ describe('App session callbacks', () => { expect(mockMcp.restartServer).toHaveBeenCalledWith('filesystem'); }); + it('observes MCP config reconciliation and reports a timeout', async () => { + const server = { + name: 'filesystem', + source: 'project' as const, + configOrigin: 'workspace_settings' as const, + disabled: false, + mcpStatus: 'connected' as const, + resourceCount: 0, + }; + mockMcp.reload.mockResolvedValue({ + initialized: true, + discoveryState: 'completed', + workspaceCwd: '/workspace', + source: 'live', + runtimeEpoch: 1, + servers: [server], + }); + mockMcp.loadTools.mockResolvedValue({ + serverName: 'filesystem', + runtimeEpoch: 1, + tools: [], + }); + mockMcp.setConfigServerEnabled.mockResolvedValue({ + changed: true, + activation: 'reconciling', + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/mcp'; + await clickSubmit(container); + await flush(); + await act(async () => { + container + .querySelector('[aria-label="filesystem"]') + ?.click(); + await Promise.resolve(); + }); + await flush(); + await act(async () => { + container + .querySelector('[data-testid="mcp-server-actions"]') + ?.dispatchEvent( + new MouseEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + }), + ); + await Promise.resolve(); + }); + + mockMcp.reload.mockClear(); + mockMcp.reload.mockResolvedValue({ + initialized: true, + discoveryState: 'completed', + workspaceCwd: '/workspace', + source: 'live', + runtimeEpoch: 1, + servers: [{ ...server, disabled: true }], + }); + await act(async () => { + document + .querySelector('[data-testid="mcp-server-action-disable"]') + ?.click(); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(mockMcp.setConfigServerEnabled).toHaveBeenCalledWith( + 'filesystem', + 'workspace', + false, + ); + }); + + expect(mockMcp.reloadConfig).not.toHaveBeenCalled(); + expect(mockMcp.reload).toHaveBeenCalled(); + vi.useFakeTimers(); + await act(async () => { + container + .querySelector('[data-testid="mcp-server-actions"]') + ?.dispatchEvent( + new MouseEvent('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + }), + ); + await Promise.resolve(); + }); + + mockMcp.runtimeStatus.mockResolvedValue({ + runtimeLive: true, + runtimeEpoch: 1, + capabilities: { + mcp: { state: 'starting', revision: 1, runtimeEpoch: 1 }, + }, + }); + await act(async () => { + document + .querySelector('[data-testid="mcp-server-action-enable"]') + ?.click(); + await Promise.resolve(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(81 * 1_500); + }); + await flush(); + + expect(container.textContent).toContain( + 'Configuration was saved, but the current session was not updated.', + ); + expect(container.textContent).not.toContain('Enable complete.'); + }); + + it('waits for the current MCP runtime before refreshing a status message', async () => { + vi.useFakeTimers(); + mockMcp.manageServer.mockResolvedValue({ + serverName: 'filesystem', + action: 'enable', + ok: true, + }); + mockMcp.runtimeStatus + .mockResolvedValueOnce({ + runtimeLive: true, + runtimeEpoch: 2, + capabilities: { + mcp: { state: 'starting', revision: 1, runtimeEpoch: 2 }, + }, + }) + .mockResolvedValue({ + runtimeLive: true, + runtimeEpoch: 2, + capabilities: { + mcp: { state: 'ready', revision: 1, runtimeEpoch: 2 }, + }, + }); + mockMcp.reload.mockResolvedValue({ + initialized: true, + discoveryState: 'completed', + workspaceCwd: '/workspace', + source: 'live', + runtimeEpoch: 2, + servers: [ + { + name: 'filesystem', + disabled: false, + mcpStatus: 'connected', + }, + ], + }); + mockMcp.loadTools.mockResolvedValue({ + serverName: 'filesystem', + runtimeEpoch: 2, + tools: [], + }); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + await act(async () => { + root.render( + + + , + ); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + await act(async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + await Promise.resolve(); + }); + + expect(mockMcp.manageServer).toHaveBeenCalledWith('filesystem', 'enable'); + expect(mockMcp.runtimeStatus).toHaveBeenCalledOnce(); + expect(mockMcp.reload).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500); + }); + await flush(); + + expect(mockMcp.runtimeStatus).toHaveBeenCalledTimes(2); + expect(mockMcp.reload).toHaveBeenCalledOnce(); + expect(container.textContent).toContain('Enable complete.'); + }); + it('polls workspace MCP status until browser authentication completes', async () => { vi.useFakeTimers(); const disconnectedServer = { @@ -18606,6 +19002,8 @@ describe('App session callbacks', () => { initialized: true, discoveryState: 'completed', workspaceCwd: '/workspace', + runtimeEpoch: 1, + source: 'live', servers: [disconnectedServer], }); mockMcp.loadTools.mockResolvedValue({ serverName: 'yuque', tools: [] }); @@ -18622,6 +19020,8 @@ describe('App session callbacks', () => { initialized: true, discoveryState: 'completed', workspaceCwd: '/workspace', + runtimeEpoch: 1, + source: 'live', servers: [ { ...disconnectedServer, authenticationState: 'pending' as const }, ], @@ -18630,6 +19030,8 @@ describe('App session callbacks', () => { initialized: true, discoveryState: 'completed', workspaceCwd: '/workspace', + runtimeEpoch: 1, + source: 'live', servers: [ { ...disconnectedServer, @@ -18705,6 +19107,15 @@ describe('App session callbacks', () => { await clickSubmit(container); await flush(); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + expect(container.textContent).toContain('Loading MCP status...'); + expect( + container.querySelector('input[placeholder="Search MCP…"]'), + ).not.toBeNull(); + expect(container.textContent).not.toContain('Initializing MCP servers'); + expect(container.textContent).not.toContain( + 'The first startup can take longer', + ); expect(container.textContent).not.toContain('Loading MCP tools...'); }); @@ -18712,6 +19123,8 @@ describe('App session callbacks', () => { mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ initialized: true, discoveryState: 'completed', + source: 'live', + runtimeEpoch: 1, servers: [], }); const { container } = renderApp(); @@ -18721,6 +19134,8 @@ describe('App session callbacks', () => { await clickSubmit(container); await flush(); + expect(mockMcp.ensureRuntime).not.toHaveBeenCalled(); + expect(mockMcp.runtimeStatus).toHaveBeenCalledOnce(); expect(mockMcp.initialize).not.toHaveBeenCalled(); expect(mockMcp.reloadConfig).not.toHaveBeenCalled(); expect(container.textContent).not.toContain('MCP tools are ready.'); @@ -18737,6 +19152,8 @@ describe('App session callbacks', () => { mockMcp.reload.mockResolvedValue({ initialized: true, discoveryState: 'completed', + runtimeEpoch: 1, + source: 'live', servers: [], }); const { container } = renderApp(); @@ -18745,6 +19162,7 @@ describe('App session callbacks', () => { testState.prompt = '/mcp'; await clickSubmit(container); await flush(); + expect(mockMcp.reload).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain('Loading MCP tools...'); await act(async () => { @@ -18752,10 +19170,53 @@ describe('App session callbacks', () => { }); await flush(); + expect(mockMcp.reload).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain('Loading MCP tools...'); expect(container.textContent).not.toContain('MCP tools are ready.'); }); + it('waits for a live MCP catalog from the current runtime epoch', async () => { + vi.useFakeTimers(); + mockWorkspaceActions.loadMcpStatus.mockResolvedValue({ + initialized: true, + discoveryState: 'starting', + servers: [], + }); + mockMcp.reload + .mockResolvedValueOnce({ + initialized: true, + discoveryState: 'completed', + runtimeEpoch: 0, + source: 'cache', + servers: [{ name: 'stale' }], + }) + .mockResolvedValueOnce({ + initialized: true, + discoveryState: 'completed', + runtimeEpoch: 1, + source: 'live', + servers: [], + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/mcp'; + await clickSubmit(container); + await flush(); + + expect(mockMcp.reload).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain('stale'); + expect(container.querySelector('[role="status"]')).not.toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500); + }); + await flush(); + + expect(mockMcp.reload).toHaveBeenCalledTimes(2); + expect(container.querySelector('[role="status"]')).toBeNull(); + }); + it('auto-closes an open panel when an AskUserQuestion approval becomes pending', async () => { // The auto-close effect gates on pendingToolApproval || pendingAskUserApproval; // this covers the second branch (ask_user_question resolves to diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index cfcdc1139f6..0fb9bab1ad3 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -5512,17 +5512,6 @@ export function App({ }, [], ); - const loadMcpManagerMessage = useCallback(async () => { - const status = await workspaceActions.loadMcpStatus(); - setMcpDialogMessage({ - status, - toolsByServer: {}, - resourcesByServer: {}, - showDescriptions: false, - showSchema: false, - showTips: false, - }); - }, [workspaceActions]); const openScheduledTasks = useCallback(() => { setActivePanel(null); setMainView('scheduledTasks'); @@ -13175,15 +13164,6 @@ export function App({ /> ) : activePanel === 'plugins' ? ( { - try { - await loadMcpManagerMessage(); - } catch (error) { - reportError(error, 'Failed to load MCP status'); - throw error; - } - }} onClose={closePanel} onUseSkill={handleUseSkill} initialFocusRef={pluginTabRef} diff --git a/packages/web-shell/client/components/mcp/McpManagerPage.tsx b/packages/web-shell/client/components/mcp/McpManagerPage.tsx index 72d7ae90905..5f3e5513975 100644 --- a/packages/web-shell/client/components/mcp/McpManagerPage.tsx +++ b/packages/web-shell/client/components/mcp/McpManagerPage.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; import { AlertCircleIcon, ArrowLeftIcon, @@ -19,7 +26,7 @@ import type { DaemonWorkspaceMcpToolStatus, DaemonWorkspaceMcpToolsStatus, } from '@qwen-code/web-shell/daemon-react-sdk'; -import { useMcp, useSettings } from '@qwen-code/web-shell/daemon-react-sdk'; +import { useMcp } from '@qwen-code/web-shell/daemon-react-sdk'; import { useI18n } from '../../i18n'; import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener'; import { extractErrorDetail } from '../../utils/errorDetail'; @@ -109,9 +116,11 @@ type McpServerAction = { }; interface McpManagerPageProps { - message: SerializedMcpStatusMessage; + message?: SerializedMcpStatusMessage; onClose: () => void; embedded?: EmbeddedManagerPage; + workspaceCwd?: string; + workspaceControl?: ReactNode; } function configOriginValue( @@ -136,6 +145,12 @@ function sourceValue(server: DaemonWorkspaceMcpServerStatus): SourceFilter { return 'user'; } +function configScopeForServer( + server: DaemonWorkspaceMcpServerStatus, +): McpSettingsScope { + return configOriginValue(server) === 'user_settings' ? 'user' : 'workspace'; +} + function isManagedServerVisible( server: DaemonWorkspaceMcpServerStatus, ): boolean { @@ -156,17 +171,6 @@ function sourceLabel(server: DaemonWorkspaceMcpServerStatus, t: T): string { : t('mcp.source.user'); } -function mcpServersForScope( - settings: Awaited>, - scope: McpSettingsScope, -): Record { - const value = settings.settings.find( - (setting) => setting.key === 'mcpServers', - )?.values[scope]; - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return Object.fromEntries(Object.entries(value)); -} - function statusLabel(server: DaemonWorkspaceMcpServerStatus, t: T): string { if (server.disabled) return t('mcp.status.disabled'); if (server.approvalState === 'pending') { @@ -178,6 +182,12 @@ function statusLabel(server: DaemonWorkspaceMcpServerStatus, t: T): string { if (server.authenticationState === 'pending') { return t('mcp.status.authenticating'); } + if (server.authenticationState === 'failed') { + return t('mcp.status.authenticationFailed'); + } + if (server.requiresAuth && server.authenticationState !== 'succeeded') { + return t('mcp.status.needsAuthentication'); + } if (server.mcpStatus === 'connected') return t('mcp.status.connected'); if (server.mcpStatus === 'connecting') return t('mcp.status.connecting'); return t('mcp.status.disconnectedTitle'); @@ -186,6 +196,9 @@ function statusLabel(server: DaemonWorkspaceMcpServerStatus, t: T): string { function statusBadgeClass(server: DaemonWorkspaceMcpServerStatus): string { return !server.disabled && !server.approvalState && + server.authenticationState !== 'pending' && + server.authenticationState !== 'failed' && + (!server.requiresAuth || server.authenticationState === 'succeeded') && server.mcpStatus === 'connected' ? styles.connectedBadge : ''; @@ -377,18 +390,33 @@ export function McpManagerPage({ message, onClose, embedded, + workspaceCwd, + workspaceControl, }: McpManagerPageProps) { const { t } = useI18n(); const openExternalLink = useExternalLinkOpener(); - const mcp = useMcp({ autoLoad: false }); - const settings = useSettings({ autoLoad: false }); - const [status, setStatus] = useState(message.status); + const mcp = useMcp({ autoLoad: false, workspaceCwd }); + const initialMessage = + message && (!workspaceCwd || message.status.workspaceCwd === workspaceCwd) + ? message + : undefined; + const [status, setStatus] = useState(() => + initialMessage + ? initialMessage.status + : { + v: 1, + workspaceCwd: workspaceCwd ?? '', + initialized: false, + discoveryState: 'not_started', + servers: [], + }, + ); const [toolsByServer, setToolsByServer] = useState< Record - >(message.toolsByServer); + >(initialMessage?.toolsByServer ?? {}); const [resourcesByServer, setResourcesByServer] = useState< Record - >(message.resourcesByServer ?? {}); + >(initialMessage?.resourcesByServer ?? {}); const [selectedServerName, setSelectedServerName] = useState( null, ); @@ -431,9 +459,7 @@ export function McpManagerPage({ const [loadErrorsByServer, setLoadErrorsByServer] = useState< Record >({}); - const hasInitializedDiscovery = useRef( - message.status.discoveryState === 'completed', - ); + const hasInitializedDiscovery = useRef(false); const mountedRef = useRef(true); useEffect(() => { @@ -448,22 +474,40 @@ export function McpManagerPage({ return mountedRef.current; }, []); - const startDiscovery = useCallback( - async ( - operation: 'initialize' | 'reload', - showProgress = true, - deferStatus = false, - ): Promise => { - await (operation === 'initialize' - ? mcp.initialize() - : mcp.reloadConfig()); - if (!mountedRef.current) return false; + const loadReadyRuntime = useCallback(async () => { + const runtimeStatus = await mcp.runtimeStatus(); + const capability = runtimeStatus.capabilities?.mcp; + if (capability?.state === 'error') { + throw new Error(capability.error?.message ?? t('mcp.discovery.timeout')); + } + return runtimeStatus.runtimeLive && + capability?.state === 'ready' && + capability.runtimeEpoch === runtimeStatus.runtimeEpoch + ? runtimeStatus + : undefined; + }, [mcp, t]); - for (let attempt = 0; attempt < 40; attempt += 1) { - if (!(await waitForPoll())) return false; - const nextStatus = await mcp.reload(); + const loadCurrentStatus = useCallback(async (): Promise< + McpStatus | undefined + > => { + const runtimeStatus = await loadReadyRuntime(); + if (!runtimeStatus) return undefined; + const nextStatus = await mcp.reload(); + return nextStatus?.source === 'live' && + nextStatus.runtimeEpoch === runtimeStatus.runtimeEpoch + ? nextStatus + : undefined; + }, [loadReadyRuntime, mcp]); + + const observeDiscovery = useCallback( + async (showProgress = true, deferStatus = false): Promise => { + for (let attempt = 0; attempt < 81; attempt += 1) { + const nextStatus = await loadCurrentStatus(); if (!mountedRef.current) return false; - if (!nextStatus) continue; + if (!nextStatus) { + if (!(await waitForPoll())) return false; + continue; + } if (!deferStatus || nextStatus.discoveryState === 'completed') { setStatus((current) => showProgress && @@ -497,20 +541,36 @@ export function McpManagerPage({ if (showProgress) setNotice({ text: t('mcp.discovery.empty') }); return true; } + if (!(await waitForPoll())) return false; } if (showProgress) { setNotice({ text: t('mcp.discovery.timeout'), error: true }); } return false; }, - [mcp, t, waitForPoll], + [loadCurrentStatus, t, waitForPoll], ); useEffect(() => { if (hasInitializedDiscovery.current) return; hasInitializedDiscovery.current = true; setInitializing(true); - void startDiscovery('initialize') + void (async () => { + if ( + initialMessage?.status.source === 'live' && + initialMessage.status.runtimeEpoch !== undefined && + initialMessage.status.discoveryState === 'completed' + ) { + const runtimeStatus = await loadReadyRuntime(); + if ( + runtimeStatus?.runtimeEpoch === initialMessage.status.runtimeEpoch + ) { + return; + } + } + await mcp.ensureRuntime(); + if (mountedRef.current) await observeDiscovery(); + })() .catch((error: unknown) => { if (mountedRef.current) { setNotice({ text: extractErrorDetail(error), error: true }); @@ -519,7 +579,7 @@ export function McpManagerPage({ .finally(() => { if (mountedRef.current) setInitializing(false); }); - }, [startDiscovery]); + }, [initialMessage, loadReadyRuntime, mcp, observeDiscovery]); const servers = useMemo( () => (status.servers ?? []).filter(isManagedServerVisible), @@ -560,6 +620,8 @@ export function McpManagerPage({ const loadServerData = useCallback( async (server: DaemonWorkspaceMcpServerStatus) => { + const runtimeStatus = await loadReadyRuntime(); + if (!runtimeStatus) throw new Error(t('mcp.discovery.timeout')); const failures: unknown[] = []; const [toolsResult, resourcesResult] = await Promise.allSettled([ mcp.loadTools(server.name), @@ -568,6 +630,9 @@ export function McpManagerPage({ : Promise.resolve(null), ]); if (toolsResult.status === 'fulfilled') { + if (toolsResult.value.runtimeEpoch !== runtimeStatus.runtimeEpoch) { + throw new Error(t('mcp.discovery.timeout')); + } setToolsByServer((current) => ({ ...current, [server.name]: toolsResult.value, @@ -589,6 +654,9 @@ export function McpManagerPage({ resourcesResult.value !== null ) { const resources = resourcesResult.value; + if (resources.runtimeEpoch !== runtimeStatus.runtimeEpoch) { + throw new Error(t('mcp.discovery.timeout')); + } setResourcesByServer((current) => ({ ...current, [server.name]: resources, @@ -623,7 +691,7 @@ export function McpManagerPage({ ); } }, - [mcp], + [loadReadyRuntime, mcp, t], ); const refreshAll = useCallback(async () => { @@ -631,15 +699,16 @@ export function McpManagerPage({ setRefreshing(true); setNotice(null); try { - const nextStatus = await mcp.reload(); - if (!nextStatus) return; - setStatus(nextStatus); + await mcp.ensureRuntime(); + if (!(await observeDiscovery(false))) { + throw new Error(t('mcp.discovery.timeout')); + } } catch (error) { setNotice({ text: extractErrorDetail(error), error: true }); } finally { setRefreshing(false); } - }, [mcp, refreshing]); + }, [mcp, observeDiscovery, refreshing, t]); const addServer = useCallback(async () => { const name = serverName.trim(); @@ -648,7 +717,7 @@ export function McpManagerPage({ setAddError(t('mcp.add.nameRequired')); return; } - let config: Parameters[0]['config']; + let config: Record; try { const parsed: unknown = JSON.parse(serverConfig); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { @@ -656,7 +725,7 @@ export function McpManagerPage({ } const description = serverDescription.trim(); config = { - ...(parsed as Parameters[0]['config']), + ...(parsed as Record), ...(description ? { description } : {}), }; } catch (error) { @@ -665,16 +734,12 @@ export function McpManagerPage({ } setAddError(null); setAdding(true); - let persisted = false; try { - await settings.setValue(serverScope, 'mcpServers', config, { - mcpServerMutation: { operation: 'set', name }, - }); - persisted = true; + const result = await mcp.setConfigServer(name, serverScope, config); setNotice(null); - const runtimeUpdated = await startDiscovery('reload', false).catch( - () => false, - ); + const runtimeUpdated = + result.activation !== 'deferred' && + (await observeDiscovery(false).catch(() => false)); setNotice({ ...(editing ? { serverName: name } : {}), text: runtimeUpdated @@ -689,20 +754,7 @@ export function McpManagerPage({ setServerDescription(''); setServerConfig(DEFAULT_MCP_SERVER_CONFIG); } catch (error) { - if (persisted) { - setNotice({ - ...(editing ? { serverName: name } : {}), - text: t('mcp.runtime.notUpdated'), - error: true, - }); - setAddDialogOpen(false); - setEditingServer(null); - setServerName(''); - setServerDescription(''); - setServerConfig(DEFAULT_MCP_SERVER_CONFIG); - } else { - setAddError(extractErrorDetail(error)); - } + setAddError(extractErrorDetail(error)); } finally { setAdding(false); } @@ -713,57 +765,25 @@ export function McpManagerPage({ serverDescription, serverName, serverScope, - settings, - startDiscovery, + observeDiscovery, t, ]); const removeServer = useCallback(async () => { if (!serverToRemove) return; setBusyServer(serverToRemove.name); - let persisted = false; try { - const currentSettings = await settings.reload(); - if (!currentSettings) { - setNotice({ - serverName: serverToRemove.name, - text: t('mcp.add.settingsUnavailable'), - error: true, - }); - return; - } - const scope: McpSettingsScope = - sourceValue(serverToRemove) === 'workspace' ? 'workspace' : 'user'; - const servers = mcpServersForScope(currentSettings, scope); - if (!(serverToRemove.name in servers)) { - setNotice({ - serverName: serverToRemove.name, - text: t('mcp.remove.notWorkspace'), - error: true, - }); - return; - } - await settings.setValue( - scope, - 'mcpServers', - {}, - { - mcpServerMutation: { - operation: 'remove', - name: serverToRemove.name, - }, - }, - ); - persisted = true; + const scope: McpSettingsScope = configScopeForServer(serverToRemove); + const result = await mcp.removeConfigServer(serverToRemove.name, scope); setServerToRemove(null); setNotice({ serverName: serverToRemove.name, text: t('mcp.action.running', { action: t('mcp.action.remove') }), progress: true, }); - const runtimeUpdated = await startDiscovery('reload', false, true).catch( - () => false, - ); + const runtimeUpdated = + result.activation !== 'deferred' && + (await observeDiscovery(false, true).catch(() => false)); if (runtimeUpdated) { setSelectedServerName(null); setSelectedToolName(null); @@ -779,15 +799,13 @@ export function McpManagerPage({ } catch (error) { setNotice({ serverName: serverToRemove.name, - text: persisted - ? t('mcp.runtime.removeNotUpdated') - : t('mcp.action.failed', { error: extractErrorDetail(error) }), + text: t('mcp.action.failed', { error: extractErrorDetail(error) }), error: true, }); } finally { setBusyServer(null); } - }, [serverToRemove, settings, startDiscovery, t]); + }, [mcp, observeDiscovery, serverToRemove, t]); const openEditServer = useCallback( async (server: DaemonWorkspaceMcpServerStatus) => { @@ -801,13 +819,8 @@ export function McpManagerPage({ setBusyServer(server.name); setNotice(null); try { - const currentSettings = await settings.reload(); - if (!currentSettings) { - throw new Error(t('mcp.add.settingsUnavailable')); - } - const storedConfig = mcpServersForScope(currentSettings, scope)[ - server.name - ]; + const configStatus = await mcp.loadConfig(); + const storedConfig = configStatus[scope][server.name]; if ( !storedConfig || typeof storedConfig !== 'object' || @@ -839,7 +852,7 @@ export function McpManagerPage({ setBusyServer(null); } }, - [busyServer, settings, t], + [busyServer, mcp, t], ); const runAction = useCallback( @@ -892,6 +905,41 @@ export function McpManagerPage({ }), ); } + } else if (action.id === 'enable' || action.id === 'disable') { + const scopes: McpSettingsScope[] = + action.id === 'enable' + ? ['user', 'workspace'] + : [configScopeForServer(server)]; + let activation: 'applied' | 'deferred' | 'reconciling' = 'deferred'; + for (const scope of scopes) { + const result = await mcp.setConfigServerEnabled( + server.name, + scope, + action.id === 'enable', + ); + if (result.activation !== 'deferred') { + activation = result.activation; + } + } + if (!mountedRef.current) return; + if (activation === 'deferred') { + setNotice({ + serverName: server.name, + text: t('mcp.runtime.notUpdated'), + error: true, + }); + return; + } + const runtimeUpdated = await observeDiscovery(false); + if (!mountedRef.current) return; + if (!runtimeUpdated) { + setNotice({ + serverName: server.name, + text: t('mcp.runtime.notUpdated'), + error: true, + }); + return; + } } else { const result = await mcp.manageServer(server.name, action.id); if (!mountedRef.current) return; @@ -912,7 +960,7 @@ export function McpManagerPage({ if (pendingAuthentication) { for (let attempt = 0; attempt < 400; attempt += 1) { if (!(await waitForPoll())) return; - const candidate = await mcp.reload(); + const candidate = await loadCurrentStatus(); if (!mountedRef.current) return; if (!candidate) continue; setStatus(candidate); @@ -947,7 +995,7 @@ export function McpManagerPage({ } if (!nextStatus) throw new Error(t('mcp.oauth.timeout')); } else { - nextStatus = await mcp.reload(); + nextStatus = await loadCurrentStatus(); if (!mountedRef.current) return; for ( let attempt = 0; @@ -958,7 +1006,7 @@ export function McpManagerPage({ attempt += 1 ) { if (!(await waitForPoll())) return; - const candidate = await mcp.reload(); + const candidate = await loadCurrentStatus(); if (!mountedRef.current) return; if (!candidate) continue; nextStatus = candidate; @@ -1007,7 +1055,16 @@ export function McpManagerPage({ if (mountedRef.current) setBusyServer(null); } }, - [busyServer, loadServerData, mcp, openEditServer, t, waitForPoll], + [ + busyServer, + loadCurrentStatus, + loadServerData, + mcp, + openEditServer, + observeDiscovery, + t, + waitForPoll, + ], ); const openServer = (server: DaemonWorkspaceMcpServerStatus) => { @@ -1031,16 +1088,7 @@ export function McpManagerPage({ setSelectedServerName(null); setSelectedToolName(null); setSelectedResourceUri(null); - setRefreshing(true); - void mcp - .reload() - .then((nextStatus) => { - if (nextStatus) setStatus(nextStatus); - }) - .catch((error: unknown) => { - setNotice({ text: extractErrorDetail(error), error: true }); - }) - .finally(() => setRefreshing(false)); + void refreshAll(); }; const showSelectedServer = () => { @@ -1116,39 +1164,42 @@ export function McpManagerPage({ : selectedServer?.name; const navigation = embedded ? ( selectedServer ? ( - - - - - - - - - - {selectedTool || selectedResource ? ( +
+ + + - - ) : ( - {selectedServer.name} - )} - - {selectedTool || selectedResource ? : null} - {selectedTool || selectedResource ? ( + + - {detailLabel} + {selectedTool || selectedResource ? ( + + + + ) : ( + {selectedServer.name} + )} - ) : null} - - + {selectedTool || selectedResource ? : null} + {selectedTool || selectedResource ? ( + + {detailLabel} + + ) : null} + + + {workspaceControl} +
) : null ) : ( standaloneNavigation @@ -1663,21 +1714,7 @@ export function McpManagerPage({ - {initializing && connectingCount === 0 ? ( - undefined} - > - - - {t('mcp.discovery.initializing')} - - {t('mcp.startingNote')} - - - ) : connectingCount > 0 ? ( + {connectingCount > 0 ? ( - {filteredServers.length ? ( + {initializing && connectingCount === 0 ? ( +
+ + {t('mcp.loadingStatus')} +
+ ) : filteredServers.length ? (
{ - const nextStatus = await mcp.reload(); - if (nextStatus) { - setLocalStatus(nextStatus); - const nextServer = - nextStatus.servers?.find( - (server) => server.name === selectedServer?.name, - ) ?? null; - if (nextServer) { - if (nextServer.approvalState) return; - const nextTools = await mcp.loadTools(nextServer.name); - setLocalToolsByServer((current) => ({ - ...current, - [nextServer.name]: nextTools, - })); + let nextStatus: DaemonWorkspaceMcpStatus | undefined; + let runtimeEpoch: number | undefined; + for (let attempt = 0; attempt < 81; attempt += 1) { + const runtimeStatus = await mcp.runtimeStatus(); + const capability = runtimeStatus.capabilities?.mcp; + if (capability?.state === 'error') { + throw new Error( + capability.error?.message ?? t('mcp.discovery.timeout'), + ); + } + if ( + runtimeStatus.runtimeLive && + capability?.state === 'ready' && + capability.runtimeEpoch === runtimeStatus.runtimeEpoch + ) { + const candidate = await mcp.reload(); + if ( + candidate?.source === 'live' && + candidate.runtimeEpoch === runtimeStatus.runtimeEpoch + ) { + nextStatus = candidate; + runtimeEpoch = runtimeStatus.runtimeEpoch; + break; + } + } + await new Promise((resolve) => window.setTimeout(resolve, 1_500)); + } + if (!nextStatus || runtimeEpoch === undefined) { + throw new Error(t('mcp.discovery.timeout')); + } + setLocalStatus(nextStatus); + const nextServer = + nextStatus.servers?.find( + (server) => server.name === selectedServer?.name, + ) ?? null; + if (nextServer) { + if (nextServer.approvalState) return; + const nextTools = await mcp.loadTools(nextServer.name); + if (nextTools.runtimeEpoch !== runtimeEpoch) { + throw new Error(t('mcp.discovery.timeout')); } + setLocalToolsByServer((current) => ({ + ...current, + [nextServer.name]: nextTools, + })); } - }, [mcp, selectedServer?.name]); + }, [mcp, selectedServer?.name, t]); const runServerAction = useCallback( async (action: McpServerAction) => { diff --git a/packages/web-shell/client/components/plugins/PluginManagerPage.test.tsx b/packages/web-shell/client/components/plugins/PluginManagerPage.test.tsx new file mode 100644 index 00000000000..8a387209c3a --- /dev/null +++ b/packages/web-shell/client/components/plugins/PluginManagerPage.test.tsx @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// @vitest-environment jsdom + +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const workspaceState = vi.hoisted(() => ({ + workspaceCwd: '/work/primary', + capabilities: { + workspaces: [ + { + id: 'primary', + cwd: '/work/primary', + displayName: 'Primary', + primary: true, + trusted: true, + }, + { + id: 'secondary', + cwd: '/work/secondary', + displayName: 'Secondary', + primary: false, + trusted: true, + }, + ], + }, +})); + +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ + useWorkspace: () => workspaceState, +})); + +vi.mock('../extensions/ExtensionsManagerPage', () => ({ + ExtensionsManagerPage: () =>
Extensions
, +})); +vi.mock('../skills/SkillsManagerPage', () => ({ + SkillsManagerPage: () =>
Skills
, +})); +vi.mock('../agents/AgentsManagerPage', () => ({ + AgentsManagerPage: () =>
Agents
, +})); +vi.mock('../mcp/McpManagerPage', () => ({ + McpManagerPage: (props: { + workspaceCwd?: string; + workspaceControl?: ReactNode; + embedded?: { onDetailChange(open: boolean): void }; + }) => ( +
+ {props.workspaceControl} + +
+ ), +})); + +const { PluginManagerPage } = await import('./PluginManagerPage'); +const { I18nProvider } = await import('../../i18n'); + +let root: Root | undefined; +let container: HTMLDivElement | undefined; + +afterEach(async () => { + await act(async () => root?.unmount()); + container?.remove(); + workspaceState.capabilities.workspaces[0]!.trusted = true; +}); + +describe('PluginManagerPage MCP workspace selection', () => { + it('shows the multi-workspace selector and locks it in MCP detail', async () => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + + , + ); + }); + + const mcpTab = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'MCP', + ); + expect(mcpTab).toBeDefined(); + await act(async () => { + mcpTab?.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + mcpTab?.click(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await vi.waitFor(() => { + expect( + container?.querySelector('button[aria-label="Workspace"]'), + ).not.toBeNull(); + }); + const rootSelector = container.querySelector( + 'button[aria-label="Workspace"]', + )!; + expect(rootSelector.disabled).toBe(false); + expect( + container + .querySelector('[data-testid="mcp-page"]') + ?.getAttribute('data-workspace'), + ).toBe('/work/primary'); + + await act(async () => rootSelector.click()); + const secondary = Array.from( + document.querySelectorAll('[role="option"]'), + ).find((option) => option.textContent?.trim() === 'Secondary'); + expect(secondary).toBeDefined(); + await act(async () => secondary?.click()); + expect( + container + .querySelector('[data-testid="mcp-page"]') + ?.getAttribute('data-workspace'), + ).toBe('/work/secondary'); + + const openDetail = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Open detail', + ); + await act(async () => { + openDetail?.click(); + }); + + const detailSelector = container.querySelector( + 'button[aria-label="Workspace"]', + ); + expect(detailSelector?.disabled).toBe(true); + }); + + it('selects the first trusted workspace', async () => { + workspaceState.capabilities.workspaces[0]!.trusted = false; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + + , + ); + }); + + const mcpTab = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'MCP', + ); + await act(async () => { + mcpTab?.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + mcpTab?.click(); + }); + + expect( + container + .querySelector('[data-testid="mcp-page"]') + ?.getAttribute('data-workspace'), + ).toBe('/work/secondary'); + }); +}); diff --git a/packages/web-shell/client/components/plugins/PluginManagerPage.tsx b/packages/web-shell/client/components/plugins/PluginManagerPage.tsx index 83ac931d50f..a66ed32daa2 100644 --- a/packages/web-shell/client/components/plugins/PluginManagerPage.tsx +++ b/packages/web-shell/client/components/plugins/PluginManagerPage.tsx @@ -1,57 +1,76 @@ -import { useCallback, useMemo, useState, type Ref } from 'react'; -import { SearchIcon, ServerIcon } from 'lucide-react'; -import type { SerializedMcpStatusMessage } from '../messages/McpStatusMessage'; +import { useCallback, useEffect, useMemo, useState, type Ref } from 'react'; +import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk'; import { AgentsManagerPage } from '../agents/AgentsManagerPage'; import { ExtensionsManagerPage } from '../extensions/ExtensionsManagerPage'; import { McpManagerPage } from '../mcp/McpManagerPage'; import { SkillsManagerPage } from '../skills/SkillsManagerPage'; -import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; -import { Button } from '../ui/button'; import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '../ui/empty'; -import { Input } from '../ui/input'; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { useI18n } from '../../i18n'; +import { workspaceLabel } from '../../utils/workspace'; import type { EmbeddedManagerPage } from './manager-page'; type PluginTab = 'extensions' | 'mcp' | 'skills' | 'agents'; interface PluginManagerPageProps { - mcpMessage: SerializedMcpStatusMessage | null; - loadMcpMessage: () => Promise; onClose: () => void; onUseSkill: (name: string) => void; initialFocusRef?: Ref; } export function PluginManagerPage({ - mcpMessage, - loadMcpMessage, onClose, onUseSkill, initialFocusRef, }: PluginManagerPageProps) { const { t } = useI18n(); + const workspace = useWorkspace(); + const workspaces = useMemo(() => { + const listed = (workspace.capabilities?.workspaces ?? []).filter( + (entry) => entry.kind !== 'live', + ); + if (listed.length > 0) return listed; + return workspace.workspaceCwd + ? [ + { + id: 'primary', + cwd: workspace.workspaceCwd, + primary: true, + trusted: true, + }, + ] + : []; + }, [workspace.capabilities?.workspaces, workspace.workspaceCwd]); + const defaultWorkspaceCwd = + workspaces.find( + (entry) => entry.cwd === workspace.workspaceCwd && entry.trusted, + )?.cwd ?? + workspaces.find((entry) => entry.primary && entry.trusted)?.cwd ?? + workspaces.find((entry) => entry.trusted)?.cwd; + const [selectedWorkspaceCwd, setSelectedWorkspaceCwd] = useState( + () => defaultWorkspaceCwd, + ); const [activeTab, setActiveTab] = useState('extensions'); const [detailOpen, setDetailOpen] = useState(false); const [pageRevision, setPageRevision] = useState(0); - const [mcpLoaded, setMcpLoaded] = useState(false); - const [mcpLoadError, setMcpLoadError] = useState(null); - const loadMcp = useCallback(() => { - setMcpLoaded(false); - setMcpLoadError(null); - void loadMcpMessage() - .then(() => setMcpLoaded(true)) - .catch((error: unknown) => { - setMcpLoadError(error instanceof Error ? error.message : String(error)); - }); - }, [loadMcpMessage]); + useEffect(() => { + if ( + selectedWorkspaceCwd && + workspaces.some( + (entry) => entry.cwd === selectedWorkspaceCwd && entry.trusted, + ) + ) { + return; + } + setSelectedWorkspaceCwd(defaultWorkspaceCwd); + }, [defaultWorkspaceCwd, selectedWorkspaceCwd, workspaces]); const resetToRoot = useCallback(() => { setDetailOpen(false); @@ -67,15 +86,43 @@ export function PluginManagerPage({ setActiveTab(nextTab); setDetailOpen(false); setPageRevision((revision) => revision + 1); - if (nextTab === 'mcp') { - loadMcp(); - } }; + const workspaceSelect = (disabled: boolean) => + activeTab === 'mcp' && workspaces.length > 1 ? ( + + ) : null; + return ( {!detailOpen ? ( -
+
{t('plugins.extensions')} @@ -84,6 +131,7 @@ export function PluginManagerPage({ {t('plugins.skills')} {t('plugins.agents')} + {workspaceSelect(false)}
) : null} @@ -107,44 +155,14 @@ export function PluginManagerPage({ onClose={onClose} embedded={embedded} /> - ) : mcpLoadError ? ( - - {t('plugins.mcpLoadFailed')} - -

{mcpLoadError}

- -
-
- ) : mcpMessage && mcpLoaded ? ( + ) : ( - ) : ( -
-
- - -
- - - - - - {t('mcp.empty')} - {t('mcp.emptyDescription')} - - -
)} diff --git a/packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.test.tsx b/packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.test.tsx index 1b8fcc63bbf..54fed0f9496 100644 --- a/packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.test.tsx +++ b/packages/web-shell/client/daemon/workspace/DaemonWorkspaceProvider.test.tsx @@ -48,6 +48,11 @@ const sdkMocks = vi.hoisted(() => { class MockDaemonClient { constructor(_opts: unknown) {} + workspaceByCwd = vi.fn(() => ({ + runtimeMcpTools: workspaceMcpTools, + runtimeMcpResources: workspaceMcpResources, + })); + capabilities = capabilities; workspaceMcp = workspaceMcp; workspaceMcpTools = workspaceMcpTools; @@ -533,7 +538,7 @@ describe('DaemonWorkspaceProvider', () => { expect(context).toBeUndefined(); }); - it('returns MCP tools fallback for older daemons', async () => { + it('propagates MCP tools failures', async () => { sdkMocks.workspaceMcpTools.mockRejectedValueOnce( new Error('missing route'), ); @@ -555,21 +560,9 @@ describe('DaemonWorkspaceProvider', () => { const workspaceActions = actions; await act(async () => { - await expect(workspaceActions.loadMcpTools('server-a')).resolves.toEqual({ - v: 1, - workspaceCwd: '', - serverName: 'server-a', - initialized: false, - acpChannelLive: false, - tools: [], - errors: [ - { - kind: 'mcp_tools', - status: 'error', - error: 'The connected daemon does not expose MCP tool details.', - }, - ], - }); + await expect(workspaceActions.loadMcpTools('server-a')).rejects.toThrow( + 'missing route', + ); }); }); @@ -609,7 +602,7 @@ describe('DaemonWorkspaceProvider', () => { expect(sdkMocks.workspaceMcpResources).toHaveBeenCalledWith('docs'); }); - it('returns MCP resources fallback for older daemons', async () => { + it('propagates MCP resources failures', async () => { sdkMocks.workspaceMcpResources.mockRejectedValueOnce( new Error('missing route'), ); @@ -632,21 +625,7 @@ describe('DaemonWorkspaceProvider', () => { await act(async () => { await expect( workspaceActions.loadMcpResources('server-a'), - ).resolves.toEqual({ - v: 1, - workspaceCwd: '', - serverName: 'server-a', - initialized: false, - acpChannelLive: false, - resources: [], - errors: [ - { - kind: 'mcp_resources', - status: 'error', - error: 'The connected daemon does not expose MCP resource details.', - }, - ], - }); + ).rejects.toThrow('missing route'); }); }); diff --git a/packages/web-shell/client/daemon/workspace/actions.test.ts b/packages/web-shell/client/daemon/workspace/actions.test.ts index 326032558fc..417e22f89aa 100644 --- a/packages/web-shell/client/daemon/workspace/actions.test.ts +++ b/packages/web-shell/client/daemon/workspace/actions.test.ts @@ -334,16 +334,104 @@ describe('workspace actions', () => { expect(activeExtensionOperations).toHaveBeenCalledOnce(); }); - it('reloads MCP settings through the daemon client', async () => { - const reloadWorkspaceMcp = vi.fn().mockResolvedValue({ accepted: true }); + it('reloads MCP through the selected workspace runtime client', async () => { + const reloadRuntimeMcp = vi.fn().mockResolvedValue({ accepted: true }); + const workspaceByCwd = vi.fn(() => ({ reloadRuntimeMcp })); const actions = createDaemonWorkspaceActions({ - getClient: () => ({ reloadWorkspaceMcp }) as unknown as DaemonClient, + getClient: () => ({ workspaceByCwd }) as unknown as DaemonClient, getWorkspaceCwd: () => '/workspace', baseUrl: 'http://daemon', }); await expect(actions.reloadMcp()).resolves.toEqual({ accepted: true }); - expect(reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(workspaceByCwd).toHaveBeenCalledWith('/workspace'); + expect(reloadRuntimeMcp).toHaveBeenCalledOnce(); + }); + + it('loads MCP status from the selected workspace runtime', async () => { + const runtimeMcp = vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: '/secondary', + initialized: false, + servers: [], + }); + const workspaceByCwd = vi.fn(() => ({ runtimeMcp })); + const actions = createDaemonWorkspaceActions({ + getClient: () => ({ workspaceByCwd }) as unknown as DaemonClient, + getWorkspaceCwd: () => '/secondary', + baseUrl: 'http://daemon', + }); + + await expect(actions.loadMcpStatus()).resolves.toMatchObject({ + workspaceCwd: '/secondary', + }); + expect(workspaceByCwd).toHaveBeenCalledWith('/secondary'); + }); + + it('persists MCP configuration in the requested scope', async () => { + const setUserMcpServer = vi + .fn() + .mockResolvedValue({ activation: 'reconciling' }); + const setMcpServer = vi + .fn() + .mockResolvedValue({ activation: 'reconciling' }); + const workspaceByCwd = vi.fn(() => ({ setMcpServer })); + const actions = createDaemonWorkspaceActions({ + getClient: () => + ({ setUserMcpServer, workspaceByCwd }) as unknown as DaemonClient, + getWorkspaceCwd: () => '/secondary', + baseUrl: 'http://daemon', + }); + + await actions.setMcpServer('user-docs', 'user', { command: 'user' }); + await actions.setMcpServer('workspace-docs', 'workspace', { + command: 'workspace', + }); + + expect(setUserMcpServer).toHaveBeenCalledWith('user-docs', { + command: 'user', + }); + expect(workspaceByCwd).toHaveBeenCalledWith('/secondary'); + expect(setMcpServer).toHaveBeenCalledWith('workspace-docs', { + command: 'workspace', + }); + }); + + it('enables an MCP server in every disabled scope', async () => { + const setUserMcpServerEnabled = vi + .fn() + .mockResolvedValue({ changed: true, activation: 'reconciling' }); + const setMcpServerEnabled = vi + .fn() + .mockResolvedValue({ changed: true, activation: 'reconciling' }); + const workspaceByCwd = vi.fn(() => ({ + mcpConfig: vi.fn().mockResolvedValue({ + user: {}, + workspace: {}, + }), + setMcpServerEnabled, + })); + const actions = createDaemonWorkspaceActions({ + getClient: () => + ({ + setUserMcpServerEnabled, + workspaceByCwd, + }) as unknown as DaemonClient, + getWorkspaceCwd: () => '/secondary', + baseUrl: 'http://daemon', + }); + + await expect(actions.manageMcpServer('docs', 'enable')).resolves.toEqual({ + serverName: 'docs', + action: 'enable', + ok: true, + changed: true, + }); + expect(setUserMcpServerEnabled).toHaveBeenCalledWith('docs', true); + expect(setMcpServerEnabled).toHaveBeenCalledWith('docs', true); + expect(setUserMcpServerEnabled.mock.invocationCallOrder[0]!).toBeLessThan( + setMcpServerEnabled.mock.invocationCallOrder[0]!, + ); }); it('forwards an extension interaction response to the daemon client', async () => { diff --git a/packages/web-shell/client/daemon/workspace/actions.ts b/packages/web-shell/client/daemon/workspace/actions.ts index d9b274ef369..8f4632afa5c 100644 --- a/packages/web-shell/client/daemon/workspace/actions.ts +++ b/packages/web-shell/client/daemon/workspace/actions.ts @@ -290,90 +290,101 @@ export function createDaemonWorkspaceActions({ }, }, + async ensureRuntime() { + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Initialize workspace runtime failed', + ); + return withActionTimeout( + workspace.ensureRuntime(), + 'Initialize workspace runtime timed out', + 62_000, + ); + }, + + async loadMcpConfig() { + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Load MCP configuration failed', + ); + return withActionTimeout( + workspace.mcpConfig(), + 'Load MCP configuration timed out', + ); + }, + async loadMcpStatus() { - const client = requireClient(getClient, 'Load MCP status failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Load MCP status failed', + ); return withActionTimeout( - client.workspaceMcp(), + workspace.runtimeMcp(), 'Load MCP status timed out', ); }, async initializeMcp() { - const client = requireClient(getClient, 'Initialize MCP failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Initialize MCP failed', + ); return withActionTimeout( - client.initializeWorkspaceMcp(), + workspace.ensureRuntime().then(() => ({ accepted: true })), 'Initialize MCP timed out', + 62_000, ); }, async reloadMcp() { - const client = requireClient(getClient, 'Reload MCP failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Reload MCP failed', + ); return withActionTimeout( - client.reloadWorkspaceMcp(), + workspace.reloadRuntimeMcp(), 'Reload MCP timed out', + 5 * 60_000, ); }, async loadMcpTools(serverName) { - const client = requireClient(getClient, 'Load MCP tools failed'); - try { - return await withActionTimeout( - client.workspaceMcpTools(serverName), - 'Load MCP tools timed out', - ); - } catch { - return { - v: 1 as const, - workspaceCwd: '', - serverName, - initialized: false, - acpChannelLive: false, - tools: [], - errors: [ - { - kind: 'mcp_tools' as const, - status: 'error' as const, - error: 'The connected daemon does not expose MCP tool details.', - }, - ], - }; - } + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Load MCP tools failed', + ); + return withActionTimeout( + workspace.runtimeMcpTools(serverName), + 'Load MCP tools timed out', + ); }, async loadMcpResources(serverName) { - const client = requireClient(getClient, 'Load MCP resources failed'); - try { - return await withActionTimeout( - client.workspaceMcpResources(serverName), - 'Load MCP resources timed out', - ); - } catch { - // Older daemons lack the resources route. Degrade gracefully so a - // mixed-version client still renders the rest of the /mcp dialog — - // mirrors the loadMcpTools fallback. - return { - v: 1 as const, - workspaceCwd: '', - serverName, - initialized: false, - acpChannelLive: false, - resources: [], - errors: [ - { - kind: 'mcp_resources' as const, - status: 'error' as const, - error: - 'The connected daemon does not expose MCP resource details.', - }, - ], - }; - } + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Load MCP resources failed', + ); + return withActionTimeout( + workspace.runtimeMcpResources(serverName), + 'Load MCP resources timed out', + ); }, async restartMcpServer(serverName) { - const client = requireClient(getClient, 'Restart MCP server failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Restart MCP server failed', + ); return withActionTimeout( - client.restartMcpServer(serverName), + workspace.restartRuntimeMcpServer(serverName), 'Restart MCP server timed out', 5 * 60_000, ); @@ -381,9 +392,41 @@ export function createDaemonWorkspaceActions({ async manageMcpServer(serverName, action) { const client = requireClient(getClient, 'Manage MCP server failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Manage MCP server failed', + ); const timeoutMs = action === 'authenticate' ? 10 * 60_000 : 5 * 60_000; + if (action === 'enable' || action === 'disable') { + const config = + action === 'disable' ? await workspace.mcpConfig() : undefined; + const scopes = + action === 'enable' + ? (['user', 'workspace'] as const) + : [ + serverName in config!.user && !(serverName in config!.workspace) + ? ('user' as const) + : ('workspace' as const), + ]; + let changed: boolean | undefined; + for (const scope of scopes) { + const result = await (scope === 'workspace' + ? workspace.setMcpServerEnabled(serverName, action === 'enable') + : client.setUserMcpServerEnabled(serverName, action === 'enable')); + if (result.changed !== undefined) { + changed = (changed ?? false) || result.changed; + } + } + return { + serverName, + action, + ok: true, + ...(changed === undefined ? {} : { changed }), + }; + } return withActionTimeout( - client.manageMcpServer(serverName, action), + workspace.manageRuntimeMcpServer(serverName, action), 'Manage MCP server timed out', timeoutMs, ); @@ -407,6 +450,42 @@ export function createDaemonWorkspaceActions({ ); }, + async setMcpServer(name, scope, config) { + const client = requireClient(getClient, 'Save MCP server failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Save MCP server failed', + ); + return scope === 'user' + ? client.setUserMcpServer(name, config) + : workspace.setMcpServer(name, config); + }, + + async removeMcpServer(name, scope) { + const client = requireClient(getClient, 'Remove MCP server failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Remove MCP server failed', + ); + return scope === 'user' + ? client.removeUserMcpServer(name) + : workspace.removeMcpServer(name); + }, + + async setMcpServerEnabled(name, scope, enabled) { + const client = requireClient(getClient, 'Update MCP server failed'); + const workspace = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Update MCP server failed', + ); + return scope === 'user' + ? client.setUserMcpServerEnabled(name, enabled) + : workspace.setMcpServerEnabled(name, enabled); + }, + async loadDaemonStatus(detail) { const client = requireClient(getClient, 'Load daemon status failed'); return withActionTimeout( diff --git a/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts index d51361664f2..4ec5f2b46d6 100644 --- a/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts +++ b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts @@ -4,36 +4,146 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useDaemonWorkspaceEventSignals } from '../../session/DaemonSessionProvider.js'; -import { useDaemonWorkspaceActions } from '../DaemonWorkspaceProvider.js'; +import { useDaemonWorkspace } from '../DaemonWorkspaceProvider.js'; import type { DaemonResourceOptions } from '../types.js'; import { useDaemonResource } from './useDaemonResource.js'; import { useWorkspaceEventReload } from './useWorkspaceEventReload.js'; -export function useDaemonMcp(options: DaemonResourceOptions = {}) { - const workspaceActions = useDaemonWorkspaceActions(); +interface DaemonMcpOptions extends DaemonResourceOptions { + workspaceCwd?: string; +} + +export function useDaemonMcp(options: DaemonMcpOptions = {}) { + const { actions, client, workspaceCwd } = useDaemonWorkspace(); + const { workspaceCwd: requestedWorkspaceCwd, ...resourceOptions } = options; + const workspaceClient = useMemo( + () => + requestedWorkspaceCwd + ? client.workspaceByCwd(requestedWorkspaceCwd) + : undefined, + [client, requestedWorkspaceCwd], + ); const load = useCallback( - () => workspaceActions.loadMcpStatus(), - [workspaceActions], + () => + workspaceClient ? workspaceClient.runtimeMcp() : actions.loadMcpStatus(), + [actions, workspaceClient], ); - const result = useDaemonResource(load, options); + const result = useDaemonResource(load, resourceOptions); const signals = useDaemonWorkspaceEventSignals(); useWorkspaceEventReload( signals?.mcpVersion, result.reload, - options.autoLoad === true || result.data !== undefined, + resourceOptions.autoLoad === true || result.data !== undefined, ); return { ...result, status: result.data, - initialize: workspaceActions.initializeMcp, - reloadConfig: workspaceActions.reloadMcp, - loadTools: workspaceActions.loadMcpTools, - loadResources: workspaceActions.loadMcpResources, - restartServer: workspaceActions.restartMcpServer, - manageServer: workspaceActions.manageMcpServer, - addServer: workspaceActions.addRuntimeMcpServer, - removeServer: workspaceActions.removeRuntimeMcpServer, + ensureRuntime: () => + workspaceClient + ? workspaceClient.ensureRuntime() + : actions.ensureRuntime(), + runtimeStatus: () => + workspaceClient + ? workspaceClient.runtimeStatus() + : workspaceCwd + ? client.workspaceByCwd(workspaceCwd).runtimeStatus() + : Promise.reject(new Error('Workspace is unavailable.')), + loadConfig: () => + workspaceClient ? workspaceClient.mcpConfig() : actions.loadMcpConfig(), + initialize: () => + workspaceClient + ? workspaceClient.ensureRuntime().then(() => ({ accepted: true })) + : actions.initializeMcp(), + reloadConfig: () => + workspaceClient + ? workspaceClient.reloadRuntimeMcp() + : actions.reloadMcp(), + loadTools: (serverName: string) => + workspaceClient + ? workspaceClient.runtimeMcpTools(serverName) + : actions.loadMcpTools(serverName), + loadResources: (serverName: string) => + workspaceClient + ? workspaceClient.runtimeMcpResources(serverName) + : actions.loadMcpResources(serverName), + restartServer: (serverName: string) => + workspaceClient + ? workspaceClient.restartRuntimeMcpServer(serverName) + : actions.restartMcpServer(serverName), + manageServer: async ( + serverName: string, + action: Parameters[1], + ) => { + if (!workspaceClient) return actions.manageMcpServer(serverName, action); + if (action !== 'enable' && action !== 'disable') { + return workspaceClient.manageRuntimeMcpServer(serverName, action); + } + const config = + action === 'disable' ? await workspaceClient.mcpConfig() : undefined; + const scopes = + action === 'enable' + ? (['user', 'workspace'] as const) + : [ + serverName in config!.user && !(serverName in config!.workspace) + ? ('user' as const) + : ('workspace' as const), + ]; + let changed: boolean | undefined; + for (const scope of scopes) { + const result = await (scope === 'user' + ? client.setUserMcpServerEnabled(serverName, action === 'enable') + : workspaceClient.setMcpServerEnabled( + serverName, + action === 'enable', + )); + if (result.changed !== undefined) { + changed = (changed ?? false) || result.changed; + } + } + return { + serverName, + action, + ok: true as const, + ...(changed === undefined ? {} : { changed }), + }; + }, + addServer: (request: Parameters[0]) => + workspaceClient + ? Promise.reject( + new Error( + 'Runtime-only MCP servers are unavailable outside the primary workspace.', + ), + ) + : actions.addRuntimeMcpServer(request), + removeServer: (name: string) => + workspaceClient + ? Promise.reject( + new Error( + 'Runtime-only MCP servers are unavailable outside the primary workspace.', + ), + ) + : actions.removeRuntimeMcpServer(name), + setConfigServer: ( + name: string, + scope: 'user' | 'workspace', + config: Record, + ) => + scope === 'workspace' && workspaceClient + ? workspaceClient.setMcpServer(name, config) + : actions.setMcpServer(name, scope, config), + removeConfigServer: (name: string, scope: 'user' | 'workspace') => + scope === 'workspace' && workspaceClient + ? workspaceClient.removeMcpServer(name) + : actions.removeMcpServer(name, scope), + setConfigServerEnabled: ( + name: string, + scope: 'user' | 'workspace', + enabled: boolean, + ) => + scope === 'workspace' && workspaceClient + ? workspaceClient.setMcpServerEnabled(name, enabled) + : actions.setMcpServerEnabled(name, scope, enabled), }; } diff --git a/packages/web-shell/client/daemon/workspace/types.ts b/packages/web-shell/client/daemon/workspace/types.ts index 996ed8b6f86..12647a2cbe8 100644 --- a/packages/web-shell/client/daemon/workspace/types.ts +++ b/packages/web-shell/client/daemon/workspace/types.ts @@ -63,7 +63,12 @@ import type { DaemonWorkspaceFileWriteRequest, DaemonWorkspaceFileWriteResult, DaemonWorkspaceMcpStatus, + DaemonWorkspaceMcpConfigStatus, + DaemonMcpConfigMutationResult, + DaemonMcpConfigScope, + DaemonWorkspaceRuntimeStatus, DaemonWorkspaceMcpInitializeResult, + DaemonWorkspaceMcpReloadResult, DaemonWorkspaceMcpToolsStatus, DaemonWorkspaceMcpResourcesStatus, DaemonWorkspaceMemoryStatus, @@ -427,9 +432,11 @@ export interface DaemonWorkspaceActions { channelPairing: DaemonChannelPairingActions; // MCP + ensureRuntime(): Promise; + loadMcpConfig(): Promise; loadMcpStatus(): Promise; initializeMcp(): Promise; - reloadMcp(): Promise; + reloadMcp(): Promise; loadMcpTools(serverName: string): Promise; loadMcpResources( serverName: string, @@ -443,6 +450,20 @@ export interface DaemonWorkspaceActions { request: DaemonRuntimeMcpAddRequest, ): Promise; removeRuntimeMcpServer(name: string): Promise; + setMcpServer( + name: string, + scope: DaemonMcpConfigScope, + config: Record, + ): Promise; + removeMcpServer( + name: string, + scope: DaemonMcpConfigScope, + ): Promise; + setMcpServerEnabled( + name: string, + scope: DaemonMcpConfigScope, + enabled: boolean, + ): Promise; // Daemon status (read-only) loadDaemonStatus( diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 32d307358ae..81405b6e39e 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2097,7 +2097,6 @@ const EN: Messages = { 'mcp.runtime.removeNotUpdated': 'Configuration was deleted, but the current session was not updated.', 'mcp.discovery.empty': 'No MCP servers are configured.', - 'mcp.discovery.initializing': 'Initializing MCP servers…', 'mcp.discovery.timeout': 'MCP discovery is taking longer than expected. Refresh to check again.', 'mcp.add.settingsUnavailable': 'Workspace settings are unavailable.', @@ -2163,6 +2162,8 @@ const EN: Messages = { 'mcp.status': 'Status', 'mcp.status.blocked': 'Blocked', 'mcp.status.authenticating': 'Authenticating', + 'mcp.status.authenticationFailed': 'Authentication failed', + 'mcp.status.needsAuthentication': 'Needs authentication', 'mcp.status.needsApproval': 'Needs approval', 'mcp.status.rejected': 'Rejected', 'mcp.status.connected': 'connected', @@ -2513,7 +2514,6 @@ const EN: Messages = { 'plugins.extensions': 'Extensions', 'plugins.agents': 'Agents', 'plugins.mcp': 'MCP', - 'plugins.mcpLoadFailed': 'Failed to load MCP status', 'plugins.sections': 'Plugin sections', 'plugins.skills': 'Skills', 'plugins.title': 'Plugins', @@ -5244,7 +5244,6 @@ const ZH: Messages = { 'mcp.runtime.notUpdated': '配置已保存,但未同步当前会话。', 'mcp.runtime.removeNotUpdated': '配置已删除,但未同步当前会话。', 'mcp.discovery.empty': '尚未配置 MCP 服务器。', - 'mcp.discovery.initializing': '正在初始化 MCP 服务器…', 'mcp.discovery.timeout': 'MCP 发现耗时较长,请刷新后查看状态。', 'mcp.add.settingsUnavailable': '无法读取工作区设置。', 'mcp.action.remove': '删除', @@ -5304,6 +5303,8 @@ const ZH: Messages = { 'mcp.status': '状态', 'mcp.status.blocked': '已阻止', 'mcp.status.authenticating': '认证中', + 'mcp.status.authenticationFailed': '认证失败', + 'mcp.status.needsAuthentication': '待认证', 'mcp.status.needsApproval': '待审批', 'mcp.status.rejected': '已拒绝', 'mcp.status.connected': '已连接', @@ -5631,7 +5632,6 @@ const ZH: Messages = { 'plugins.extensions': '扩展', 'plugins.agents': '智能体', 'plugins.mcp': 'MCP', - 'plugins.mcpLoadFailed': 'MCP 状态加载失败', 'plugins.sections': '插件分类', 'plugins.skills': '技能', 'plugins.title': '插件', From 7513555fd61082d88650a654ba445fdd7ff5df91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Wed, 2 Sep 2026 01:23:29 +0800 Subject: [PATCH 3/5] fix(serve): address workspace MCP review findings --- .../src/serve/routes/workspace-runtime-mcp.ts | 33 ++- .../serve/workspace-qualified-rest.test.ts | 39 +++ .../workspace-runtime-coordinator.test.ts | 87 +++++++ .../serve/workspace-runtime-coordinator.ts | 18 +- packages/web-shell/client/App.test.tsx | 1 + .../components/messages/McpStatusMessage.tsx | 1 + .../client/daemon/workspace/actions.ts | 3 +- .../workspace/hooks/useDaemonMcp.test.tsx | 130 ++++++++++ .../daemon/workspace/hooks/useDaemonMcp.ts | 222 +++++++++--------- 9 files changed, 419 insertions(+), 115 deletions(-) create mode 100644 packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx diff --git a/packages/cli/src/serve/routes/workspace-runtime-mcp.ts b/packages/cli/src/serve/routes/workspace-runtime-mcp.ts index 481cb06a46a..bd5b3cc4193 100644 --- a/packages/cli/src/serve/routes/workspace-runtime-mcp.ts +++ b/packages/cli/src/serve/routes/workspace-runtime-mcp.ts @@ -8,7 +8,7 @@ import type { Application, Request, RequestHandler, Response } from 'express'; import type { SendBridgeError } from '../server/error-response.js'; import { createBuildWorkspaceCtx, - validateMcpRuntimeServerName, + MAX_SERVER_NAME_LENGTH, } from '../server/request-helpers.js'; import { requireTrustedWorkspaceRuntime, @@ -30,6 +30,26 @@ interface WorkspaceRuntimeMcpRoutesDeps { type ResolveRuntime = (req: Request, res: Response) => WorkspaceRuntime | null; +function validateRuntimeServerName( + name: unknown, + res: Response, +): name is string { + if ( + typeof name === 'string' && + name.length > 0 && + name.length <= MAX_SERVER_NAME_LENGTH + ) + return true; + res.status(400).json({ + error: + typeof name === 'string' && name.length > 0 + ? `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit` + : 'Server name is required and must be a non-empty string', + code: 'invalid_server_name', + }); + return false; +} + function parseReloadOptions(body: Record, res: Response) { const forceReconnectAll = body['forceReconnectAll']; const forceReconnectWhich = body['forceReconnectWhich']; @@ -115,7 +135,7 @@ function registerFor( const runtime = resolveRuntime(req, res); if (!runtime) return; const serverName = req.params['server']; - if (!validateMcpRuntimeServerName(serverName, res)) return; + if (!validateRuntimeServerName(serverName, res)) return; const route = `GET ${base}/runtime/mcp/:server/tools`; try { res @@ -130,7 +150,7 @@ function registerFor( const runtime = resolveRuntime(req, res); if (!runtime) return; const serverName = req.params['server']; - if (!validateMcpRuntimeServerName(serverName, res)) return; + if (!validateRuntimeServerName(serverName, res)) return; const route = `GET ${base}/runtime/mcp/:server/resources`; try { res @@ -156,6 +176,7 @@ function registerFor( ).runMcpRuntimeMutation(() => runtime.bridge.reloadWorkspaceMcp(options), ); + runtime.generationGuard?.assertOpen(); res.status(200).json(result); } catch (error) { deps.sendBridgeError(res, error, { route }); @@ -170,7 +191,7 @@ function registerFor( const runtime = resolveRuntime(req, res); if (!runtime) return; const serverName = req.params['server']; - if (!validateMcpRuntimeServerName(serverName, res)) return; + if (!validateRuntimeServerName(serverName, res)) return; const entryIndex = parseEntryIndex(req, res); if (entryIndex === null) return; const route = `POST ${base}/runtime/mcp/:server/restart`; @@ -184,6 +205,7 @@ function registerFor( entryIndex === undefined ? undefined : { entryIndex }, ), ); + runtime.generationGuard?.assertOpen(); res.status(200).json(result); } catch (error) { deps.sendBridgeError(res, error, { route }); @@ -199,7 +221,7 @@ function registerFor( const runtime = resolveRuntime(req, res); if (!runtime) return; const serverName = req.params['server']; - if (!validateMcpRuntimeServerName(serverName, res)) return; + if (!validateRuntimeServerName(serverName, res)) return; const route = `POST ${base}/runtime/mcp/:server/${action}`; try { const result = await getWorkspaceRuntimeCoordinator( @@ -207,6 +229,7 @@ function registerFor( ).runMcpRuntimeMutation(() => runtime.bridge.manageMcpServer(serverName, action, undefined), ); + runtime.generationGuard?.assertOpen(); res.status(200).json(result); } catch (error) { deps.sendBridgeError(res, error, { route }); diff --git a/packages/cli/src/serve/workspace-qualified-rest.test.ts b/packages/cli/src/serve/workspace-qualified-rest.test.ts index 6d651da8a51..f3627c04419 100644 --- a/packages/cli/src/serve/workspace-qualified-rest.test.ts +++ b/packages/cli/src/serve/workspace-qualified-rest.test.ts @@ -1118,6 +1118,19 @@ describe('workspace-qualified core REST', () => { restarted: true, }); + const dottedRestart = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp/foo-bar.io/restart`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + expect(dottedRestart.status).toBe(200); + expect(dottedRestart.body).toMatchObject({ + serverName: 'foo-bar.io', + restarted: true, + }); + const enable = await request(h.app) .post( `/workspaces/${encodeURIComponent(h.secondaryId)}/mcp/docs/enable`, @@ -1174,6 +1187,32 @@ describe('workspace-qualified core REST', () => { } }); + it('rejects an MCP mutation completed by a closed generation', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const runtime = h.workspaceRegistry.getByWorkspaceId(h.secondaryId)!; + vi.mocked( + runtime.workspaceService.restartMcpServer, + ).mockImplementationOnce(async (_ctx, serverName) => { + runtime.generationGuard?.close(); + return { serverName, restarted: true, durationMs: 0 }; + }); + + const res = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp/docs/restart`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + + expect(res.status).toBe(503); + expect(res.body.code).toBe('workspace_runtime_unavailable'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + it('persists workspace MCP configuration through the selected workspace route', async () => { const h = await makeHarness({ token: 'secret' }); vi.mocked(h.secondaryBridge.publishWorkspaceEvent).mockImplementationOnce( diff --git a/packages/cli/src/serve/workspace-runtime-coordinator.test.ts b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts index f26f691ee46..0a9b667cad6 100644 --- a/packages/cli/src/serve/workspace-runtime-coordinator.test.ts +++ b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts @@ -221,6 +221,34 @@ describe('WorkspaceRuntimeCoordinator', () => { expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); }); + it('keeps a queued config reload when a runtime mutation bumps the revision', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + let releaseMutation!: () => void; + const mutationGate = new Promise((resolve) => { + releaseMutation = resolve; + }); + const firstMutation = coordinator.runMcpRuntimeMutation(async () => { + await mutationGate; + return { accepted: true }; + }); + await vi.waitFor(() => expect(coordinator.hasActiveWork()).toBe(true)); + + coordinator.reconcileMcpConfiguration(); + const secondMutation = coordinator.runMcpRuntimeMutation(async () => ({ + accepted: true, + })); + releaseMutation(); + + await Promise.all([firstMutation, secondMutation]); + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp).toMatchObject({ + state: 'ready', + revision: 3, + }); + }); + it('records a background MCP reconciliation failure', async () => { const harness = makeRuntime(); const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); @@ -392,6 +420,65 @@ describe('WorkspaceRuntimeCoordinator', () => { }); }); + it('replays MCP reconciliation interrupted while draining', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + harness.getWorkspaceMcpStatus.mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + runtimeEpoch: 1, + source: 'live', + discoveryState: 'in_progress', + servers: [], + }); + + coordinator.reconcileMcpConfiguration(); + await vi.waitFor(() => { + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledTimes(2); + }); + coordinator.beginDrain(); + await vi.waitFor(() => { + expect(coordinator.status().capabilities?.mcp?.state).toBe('stale'); + }); + coordinator.cancelDrain(); + + await vi.waitFor(() => { + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledTimes(2); + expect(coordinator.status().capabilities?.mcp?.state).toBe('ready'); + }); + }); + + it('handles a queued MCP preparation rejection after ensure returns', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + let releaseMutation!: () => void; + const mutationGate = new Promise((resolve) => { + releaseMutation = resolve; + }); + const mutation = coordinator.runMcpRuntimeMutation(async () => { + await mutationGate; + return { accepted: true }; + }); + await vi.waitFor(() => expect(coordinator.hasActiveWork()).toBe(true)); + + await coordinator.ensure(0); + const unhandled: unknown[] = []; + const onUnhandled = (error: unknown) => unhandled.push(error); + process.on('unhandledRejection', onUnhandled); + try { + coordinator.beginDrain(); + releaseMutation(); + await mutation; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + it('renews the warm window on every ensure call', async () => { const harness = makeRuntime(); harness.setSnapshot({ diff --git a/packages/cli/src/serve/workspace-runtime-coordinator.ts b/packages/cli/src/serve/workspace-runtime-coordinator.ts index d68916ba45a..c0759240496 100644 --- a/packages/cli/src/serve/workspace-runtime-coordinator.ts +++ b/packages/cli/src/serve/workspace-runtime-coordinator.ts @@ -70,6 +70,8 @@ export class WorkspaceRuntimeCoordinator { private mcpRevision = 0; + private mcpConfigRevision = 0; + private mcpStatus: ServeWorkspaceRuntimeCapabilityStatus = { state: 'not_started', revision: 0, @@ -156,7 +158,11 @@ export class WorkspaceRuntimeCoordinator { new Error('ACP preheat completed without a live runtime'), ); } + const revision = this.mcpRevision; const preparation = this.prepareMcp(); + void preparation.catch((error: unknown) => { + this.recordMcpError(revision, status.runtimeEpoch, error); + }); const remainingMs = deadline - Date.now(); if (remainingMs > 0) { try { @@ -180,6 +186,7 @@ export class WorkspaceRuntimeCoordinator { reconcileMcpConfiguration(): 'deferred' | 'reconciling' { this.mcpRevision += 1; + this.mcpConfigRevision += 1; return this.scheduleMcpReconciliation(); } @@ -203,8 +210,9 @@ export class WorkspaceRuntimeCoordinator { runtimeEpoch: snapshot.runtimeEpoch, }; const revision = this.mcpRevision; + const configRevision = this.mcpConfigRevision; void this.queueMcpWork(async () => { - if (revision !== this.mcpRevision) return; + if (configRevision !== this.mcpConfigRevision) return; await this.bridge.reloadWorkspaceMcp(); await this.prepareMcpRevision(revision); }).catch((error: unknown) => { @@ -318,6 +326,14 @@ export class WorkspaceRuntimeCoordinator { !current.runtimeLive || current.runtimeEpoch !== epoch ) { + if ( + this.draining && + !this.disposed && + current.runtimeLive && + current.runtimeEpoch === epoch + ) { + this.mcpReconcileDeferred = true; + } this.mcpStatus = { state: 'stale', revision, runtimeEpoch: epoch }; return; } diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 8b73b644a6d..e3f7f7b3a82 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -18974,6 +18974,7 @@ describe('App session callbacks', () => { }); expect(mockMcp.manageServer).toHaveBeenCalledWith('filesystem', 'enable'); + expect(mockMcp.ensureRuntime).toHaveBeenCalledOnce(); expect(mockMcp.runtimeStatus).toHaveBeenCalledOnce(); expect(mockMcp.reload).not.toHaveBeenCalled(); diff --git a/packages/web-shell/client/components/messages/McpStatusMessage.tsx b/packages/web-shell/client/components/messages/McpStatusMessage.tsx index f1837244b57..e3f30a44519 100644 --- a/packages/web-shell/client/components/messages/McpStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/McpStatusMessage.tsx @@ -430,6 +430,7 @@ export function McpStatusMessage({ const reloadSelectedServer = useCallback(async () => { let nextStatus: DaemonWorkspaceMcpStatus | undefined; let runtimeEpoch: number | undefined; + await mcp.ensureRuntime(); for (let attempt = 0; attempt < 81; attempt += 1) { const runtimeStatus = await mcp.runtimeStatus(); const capability = runtimeStatus.capabilities?.mcp; diff --git a/packages/web-shell/client/daemon/workspace/actions.ts b/packages/web-shell/client/daemon/workspace/actions.ts index 8f4632afa5c..c1f5386b3ca 100644 --- a/packages/web-shell/client/daemon/workspace/actions.ts +++ b/packages/web-shell/client/daemon/workspace/actions.ts @@ -405,7 +405,8 @@ export function createDaemonWorkspaceActions({ action === 'enable' ? (['user', 'workspace'] as const) : [ - serverName in config!.user && !(serverName in config!.workspace) + Object.hasOwn(config!.user, serverName) && + !Object.hasOwn(config!.workspace, serverName) ? ('user' as const) : ('workspace' as const), ]; diff --git a/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx new file mode 100644 index 00000000000..456d6900e3b --- /dev/null +++ b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx @@ -0,0 +1,130 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// @vitest-environment jsdom + +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { actions, client, resource, workspaceClient } = vi.hoisted(() => { + const workspace = { + runtimeMcp: vi.fn(), + ensureRuntime: vi.fn(), + runtimeStatus: vi.fn(), + mcpConfig: vi.fn(), + reloadRuntimeMcp: vi.fn(), + runtimeMcpTools: vi.fn(), + runtimeMcpResources: vi.fn(), + restartRuntimeMcpServer: vi.fn(), + manageRuntimeMcpServer: vi.fn(), + setMcpServer: vi.fn(), + removeMcpServer: vi.fn(), + setMcpServerEnabled: vi.fn(), + }; + return { + actions: { + loadMcpStatus: vi.fn(), + ensureRuntime: vi.fn(), + loadMcpConfig: vi.fn(), + initializeMcp: vi.fn(), + reloadMcp: vi.fn(), + loadMcpTools: vi.fn(), + loadMcpResources: vi.fn(), + restartMcpServer: vi.fn(), + manageMcpServer: vi.fn(), + addRuntimeMcpServer: vi.fn(), + removeRuntimeMcpServer: vi.fn(), + setMcpServer: vi.fn(), + removeMcpServer: vi.fn(), + setMcpServerEnabled: vi.fn(), + }, + client: { + workspaceByCwd: vi.fn(() => workspace), + setUserMcpServerEnabled: vi.fn(), + }, + resource: { + data: undefined, + loading: false, + error: undefined, + reload: vi.fn(), + }, + workspaceClient: workspace, + }; +}); + +vi.mock('../DaemonWorkspaceProvider.js', () => ({ + useDaemonWorkspace: () => ({ + actions, + client, + workspaceCwd: '/workspace', + }), +})); +vi.mock('../../session/DaemonSessionProvider.js', () => ({ + useDaemonWorkspaceEventSignals: () => undefined, +})); +vi.mock('./useDaemonResource.js', () => ({ + useDaemonResource: () => resource, +})); +vi.mock('./useWorkspaceEventReload.js', () => ({ + useWorkspaceEventReload: () => undefined, +})); + +const { useDaemonMcp } = await import('./useDaemonMcp.js'); + +describe('useDaemonMcp', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('keeps action identities stable across renders', async () => { + let current: ReturnType | undefined; + function TestComponent() { + current = useDaemonMcp({ autoLoad: false }); + return null; + } + await act(async () => root.render(() as ReactNode)); + const first = current; + await act(async () => root.render(() as ReactNode)); + + expect(current?.initialize).toBe(first?.initialize); + expect(current?.reloadConfig).toBe(first?.reloadConfig); + expect(current?.reload).toBe(first?.reload); + expect(current?.loadTools).toBe(first?.loadTools); + }); + + it('does not treat prototype properties as user config servers', async () => { + workspaceClient.mcpConfig.mockResolvedValue({ user: {}, workspace: {} }); + workspaceClient.setMcpServerEnabled.mockResolvedValue({ changed: true }); + let current: ReturnType | undefined; + function TestComponent() { + current = useDaemonMcp({ + autoLoad: false, + workspaceCwd: '/workspace', + }); + return null; + } + await act(async () => root.render(() as ReactNode)); + await current?.manageServer('constructor', 'disable'); + + expect(client.setUserMcpServerEnabled).not.toHaveBeenCalled(); + expect(workspaceClient.setMcpServerEnabled).toHaveBeenCalledWith( + 'constructor', + false, + ); + }); +}); diff --git a/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts index 4ec5f2b46d6..13692537ad0 100644 --- a/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts +++ b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.ts @@ -37,113 +37,119 @@ export function useDaemonMcp(options: DaemonMcpOptions = {}) { result.reload, resourceOptions.autoLoad === true || result.data !== undefined, ); - return { - ...result, - status: result.data, - ensureRuntime: () => - workspaceClient - ? workspaceClient.ensureRuntime() - : actions.ensureRuntime(), - runtimeStatus: () => - workspaceClient - ? workspaceClient.runtimeStatus() - : workspaceCwd - ? client.workspaceByCwd(workspaceCwd).runtimeStatus() - : Promise.reject(new Error('Workspace is unavailable.')), - loadConfig: () => - workspaceClient ? workspaceClient.mcpConfig() : actions.loadMcpConfig(), - initialize: () => - workspaceClient - ? workspaceClient.ensureRuntime().then(() => ({ accepted: true })) - : actions.initializeMcp(), - reloadConfig: () => - workspaceClient - ? workspaceClient.reloadRuntimeMcp() - : actions.reloadMcp(), - loadTools: (serverName: string) => - workspaceClient - ? workspaceClient.runtimeMcpTools(serverName) - : actions.loadMcpTools(serverName), - loadResources: (serverName: string) => - workspaceClient - ? workspaceClient.runtimeMcpResources(serverName) - : actions.loadMcpResources(serverName), - restartServer: (serverName: string) => - workspaceClient - ? workspaceClient.restartRuntimeMcpServer(serverName) - : actions.restartMcpServer(serverName), - manageServer: async ( - serverName: string, - action: Parameters[1], - ) => { - if (!workspaceClient) return actions.manageMcpServer(serverName, action); - if (action !== 'enable' && action !== 'disable') { - return workspaceClient.manageRuntimeMcpServer(serverName, action); - } - const config = - action === 'disable' ? await workspaceClient.mcpConfig() : undefined; - const scopes = - action === 'enable' - ? (['user', 'workspace'] as const) - : [ - serverName in config!.user && !(serverName in config!.workspace) - ? ('user' as const) - : ('workspace' as const), - ]; - let changed: boolean | undefined; - for (const scope of scopes) { - const result = await (scope === 'user' - ? client.setUserMcpServerEnabled(serverName, action === 'enable') - : workspaceClient.setMcpServerEnabled( - serverName, - action === 'enable', - )); - if (result.changed !== undefined) { - changed = (changed ?? false) || result.changed; + const api = useMemo( + () => ({ + ensureRuntime: () => + workspaceClient + ? workspaceClient.ensureRuntime() + : actions.ensureRuntime(), + runtimeStatus: () => + workspaceClient + ? workspaceClient.runtimeStatus() + : workspaceCwd + ? client.workspaceByCwd(workspaceCwd).runtimeStatus() + : Promise.reject(new Error('Workspace is unavailable.')), + loadConfig: () => + workspaceClient ? workspaceClient.mcpConfig() : actions.loadMcpConfig(), + initialize: () => + workspaceClient + ? workspaceClient.ensureRuntime().then(() => ({ accepted: true })) + : actions.initializeMcp(), + reloadConfig: () => + workspaceClient + ? workspaceClient.reloadRuntimeMcp() + : actions.reloadMcp(), + loadTools: (serverName: string) => + workspaceClient + ? workspaceClient.runtimeMcpTools(serverName) + : actions.loadMcpTools(serverName), + loadResources: (serverName: string) => + workspaceClient + ? workspaceClient.runtimeMcpResources(serverName) + : actions.loadMcpResources(serverName), + restartServer: (serverName: string) => + workspaceClient + ? workspaceClient.restartRuntimeMcpServer(serverName) + : actions.restartMcpServer(serverName), + manageServer: async ( + serverName: string, + action: Parameters[1], + ) => { + if (!workspaceClient) + return actions.manageMcpServer(serverName, action); + if (action !== 'enable' && action !== 'disable') { + return workspaceClient.manageRuntimeMcpServer(serverName, action); } - } - return { - serverName, - action, - ok: true as const, - ...(changed === undefined ? {} : { changed }), - }; - }, - addServer: (request: Parameters[0]) => - workspaceClient - ? Promise.reject( - new Error( - 'Runtime-only MCP servers are unavailable outside the primary workspace.', - ), - ) - : actions.addRuntimeMcpServer(request), - removeServer: (name: string) => - workspaceClient - ? Promise.reject( - new Error( - 'Runtime-only MCP servers are unavailable outside the primary workspace.', - ), - ) - : actions.removeRuntimeMcpServer(name), - setConfigServer: ( - name: string, - scope: 'user' | 'workspace', - config: Record, - ) => - scope === 'workspace' && workspaceClient - ? workspaceClient.setMcpServer(name, config) - : actions.setMcpServer(name, scope, config), - removeConfigServer: (name: string, scope: 'user' | 'workspace') => - scope === 'workspace' && workspaceClient - ? workspaceClient.removeMcpServer(name) - : actions.removeMcpServer(name, scope), - setConfigServerEnabled: ( - name: string, - scope: 'user' | 'workspace', - enabled: boolean, - ) => - scope === 'workspace' && workspaceClient - ? workspaceClient.setMcpServerEnabled(name, enabled) - : actions.setMcpServerEnabled(name, scope, enabled), - }; + const config = + action === 'disable' ? await workspaceClient.mcpConfig() : undefined; + const scopes = + action === 'enable' + ? (['user', 'workspace'] as const) + : [ + Object.hasOwn(config!.user, serverName) && + !Object.hasOwn(config!.workspace, serverName) + ? ('user' as const) + : ('workspace' as const), + ]; + let changed: boolean | undefined; + for (const scope of scopes) { + const result = await (scope === 'user' + ? client.setUserMcpServerEnabled(serverName, action === 'enable') + : workspaceClient.setMcpServerEnabled( + serverName, + action === 'enable', + )); + if (result.changed !== undefined) { + changed = (changed ?? false) || result.changed; + } + } + return { + serverName, + action, + ok: true as const, + ...(changed === undefined ? {} : { changed }), + }; + }, + addServer: ( + request: Parameters[0], + ) => + workspaceClient + ? Promise.reject( + new Error( + 'Runtime-only MCP servers are unavailable outside the primary workspace.', + ), + ) + : actions.addRuntimeMcpServer(request), + removeServer: (name: string) => + workspaceClient + ? Promise.reject( + new Error( + 'Runtime-only MCP servers are unavailable outside the primary workspace.', + ), + ) + : actions.removeRuntimeMcpServer(name), + setConfigServer: ( + name: string, + scope: 'user' | 'workspace', + config: Record, + ) => + scope === 'workspace' && workspaceClient + ? workspaceClient.setMcpServer(name, config) + : actions.setMcpServer(name, scope, config), + removeConfigServer: (name: string, scope: 'user' | 'workspace') => + scope === 'workspace' && workspaceClient + ? workspaceClient.removeMcpServer(name) + : actions.removeMcpServer(name, scope), + setConfigServerEnabled: ( + name: string, + scope: 'user' | 'workspace', + enabled: boolean, + ) => + scope === 'workspace' && workspaceClient + ? workspaceClient.setMcpServerEnabled(name, enabled) + : actions.setMcpServerEnabled(name, scope, enabled), + }), + [actions, client, workspaceClient, workspaceCwd], + ); + return { ...result, status: result.data, ...api }; } From 4a38d5d15c0544618bbfa6890ccbf1e658707272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Wed, 2 Sep 2026 07:38:20 +0800 Subject: [PATCH 4/5] fix(serve): address follow-up MCP review findings --- .../src/serve/routes/workspace-runtime-mcp.ts | 43 +++++--- .../serve/workspace-qualified-rest.test.ts | 34 ++++++ .../workspace-runtime-coordinator.test.ts | 102 +++++++++++++----- .../serve/workspace-runtime-coordinator.ts | 28 ++++- .../components/messages/McpStatusMessage.tsx | 6 +- .../client/daemon/workspace/actions.test.ts | 28 +++++ .../workspace/hooks/useDaemonMcp.test.tsx | 13 ++- 7 files changed, 207 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/serve/routes/workspace-runtime-mcp.ts b/packages/cli/src/serve/routes/workspace-runtime-mcp.ts index bd5b3cc4193..c1d586abdfd 100644 --- a/packages/cli/src/serve/routes/workspace-runtime-mcp.ts +++ b/packages/cli/src/serve/routes/workspace-runtime-mcp.ts @@ -19,7 +19,7 @@ import type { WorkspaceRegistry, WorkspaceRuntime, } from '../workspace-registry.js'; -import { getWorkspaceRuntimeCoordinator } from '../workspace-runtime-coordinator.js'; +import { getWorkspaceRuntimeCoordinatorIfSupported } from '../workspace-runtime-coordinator.js'; interface WorkspaceRuntimeMcpRoutesDeps { workspaceRegistry: WorkspaceRegistry; @@ -37,19 +37,34 @@ function validateRuntimeServerName( if ( typeof name === 'string' && name.length > 0 && - name.length <= MAX_SERVER_NAME_LENGTH + name.length <= MAX_SERVER_NAME_LENGTH && + name !== '__proto__' && + name !== 'constructor' && + name !== 'prototype' ) return true; res.status(400).json({ error: - typeof name === 'string' && name.length > 0 - ? `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit` - : 'Server name is required and must be a non-empty string', + name === '__proto__' || name === 'constructor' || name === 'prototype' + ? 'Server name must not be a reserved JS property name' + : typeof name === 'string' && name.length > 0 + ? `Server name exceeds ${MAX_SERVER_NAME_LENGTH}-character limit` + : 'Server name is required and must be a non-empty string', code: 'invalid_server_name', }); return false; } +function requireRuntimeCoordinator(runtime: WorkspaceRuntime, res: Response) { + const coordinator = getWorkspaceRuntimeCoordinatorIfSupported(runtime); + if (coordinator) return coordinator; + res.status(501).json({ + error: 'Workspace runtime lifecycle is not supported', + code: 'workspace_runtime_not_supported', + }); + return undefined; +} + function parseReloadOptions(body: Record, res: Response) { const forceReconnectAll = body['forceReconnectAll']; const forceReconnectWhich = body['forceReconnectWhich']; @@ -167,13 +182,13 @@ function registerFor( async (req, res) => { const runtime = resolveRuntime(req, res); if (!runtime) return; + const coordinator = requireRuntimeCoordinator(runtime, res); + if (!coordinator) return; const options = parseReloadOptions(deps.safeBody(req), res); if (!options) return; const route = `POST ${base}/runtime/mcp/reload`; try { - const result = await getWorkspaceRuntimeCoordinator( - runtime, - ).runMcpRuntimeMutation(() => + const result = await coordinator.runMcpRuntimeMutation(() => runtime.bridge.reloadWorkspaceMcp(options), ); runtime.generationGuard?.assertOpen(); @@ -190,15 +205,15 @@ function registerFor( async (req, res) => { const runtime = resolveRuntime(req, res); if (!runtime) return; + const coordinator = requireRuntimeCoordinator(runtime, res); + if (!coordinator) return; const serverName = req.params['server']; if (!validateRuntimeServerName(serverName, res)) return; const entryIndex = parseEntryIndex(req, res); if (entryIndex === null) return; const route = `POST ${base}/runtime/mcp/:server/restart`; try { - const result = await getWorkspaceRuntimeCoordinator( - runtime, - ).runMcpRuntimeMutation(() => + const result = await coordinator.runMcpRuntimeMutation(() => runtime.workspaceService.restartMcpServer( createBuildWorkspaceCtx(runtime.workspaceCwd)(route), serverName, @@ -220,13 +235,13 @@ function registerFor( async (req, res) => { const runtime = resolveRuntime(req, res); if (!runtime) return; + const coordinator = requireRuntimeCoordinator(runtime, res); + if (!coordinator) return; const serverName = req.params['server']; if (!validateRuntimeServerName(serverName, res)) return; const route = `POST ${base}/runtime/mcp/:server/${action}`; try { - const result = await getWorkspaceRuntimeCoordinator( - runtime, - ).runMcpRuntimeMutation(() => + const result = await coordinator.runMcpRuntimeMutation(() => runtime.bridge.manageMcpServer(serverName, action, undefined), ); runtime.generationGuard?.assertOpen(); diff --git a/packages/cli/src/serve/workspace-qualified-rest.test.ts b/packages/cli/src/serve/workspace-qualified-rest.test.ts index f3627c04419..c0012264a1c 100644 --- a/packages/cli/src/serve/workspace-qualified-rest.test.ts +++ b/packages/cli/src/serve/workspace-qualified-rest.test.ts @@ -1131,6 +1131,18 @@ describe('workspace-qualified core REST', () => { restarted: true, }); + for (const name of ['constructor', '__proto__', 'prototype']) { + const reserved = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp/${name}/clear-auth`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + expect(reserved.status).toBe(400); + expect(reserved.body.code).toBe('invalid_server_name'); + } + const enable = await request(h.app) .post( `/workspaces/${encodeURIComponent(h.secondaryId)}/mcp/docs/enable`, @@ -1187,6 +1199,28 @@ describe('workspace-qualified core REST', () => { } }); + it('reports unsupported lifecycle for runtime MCP mutations', async () => { + const h = await makeHarness({ token: 'secret' }); + try { + const runtime = h.workspaceRegistry.getByWorkspaceId(h.secondaryId)!; + delete (runtime.bridge as Partial) + .getWorkspaceRuntimeLifecycleSnapshot; + + const res = await request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondaryId)}/runtime/mcp/reload`, + ) + .set('Authorization', 'Bearer secret') + .set('Host', host()) + .send({}); + + expect(res.status).toBe(501); + expect(res.body.code).toBe('workspace_runtime_not_supported'); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + it('rejects an MCP mutation completed by a closed generation', async () => { const h = await makeHarness({ token: 'secret' }); try { diff --git a/packages/cli/src/serve/workspace-runtime-coordinator.test.ts b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts index 0a9b667cad6..ecded8bb665 100644 --- a/packages/cli/src/serve/workspace-runtime-coordinator.test.ts +++ b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts @@ -5,9 +5,10 @@ */ import { describe, expect, it, vi } from 'vitest'; -import type { - AcpSessionBridge, - BridgeWorkspaceRuntimeLifecycleSnapshot, +import { + WorkspaceDrainingError, + type AcpSessionBridge, + type BridgeWorkspaceRuntimeLifecycleSnapshot, } from './acp-session-bridge.js'; import type { WorkspaceRuntime } from './workspace-registry.js'; import { @@ -270,7 +271,10 @@ describe('WorkspaceRuntimeCoordinator', () => { it('preheats a cold runtime before an MCP mutation', async () => { const harness = makeRuntime(); - const mutation = vi.fn(async () => ({ accepted: true })); + const mutation = vi.fn(async () => { + expect(harness.preheat).toHaveBeenCalledOnce(); + return { accepted: true }; + }); const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); await expect(coordinator.runMcpRuntimeMutation(mutation)).resolves.toEqual({ @@ -286,6 +290,17 @@ describe('WorkspaceRuntimeCoordinator', () => { }); }); + it('wraps a failed MCP mutation preheat as an initialization failure', async () => { + const harness = makeRuntime(); + harness.preheat.mockRejectedValueOnce(new Error('child failed')); + + await expect( + getWorkspaceRuntimeCoordinator(harness.runtime).runMcpRuntimeMutation( + async () => ({ accepted: true }), + ), + ).rejects.toBeInstanceOf(WorkspaceRuntimeInitializationError); + }); + it('rechecks MCP readiness after a rejected runtime mutation', async () => { const harness = makeRuntime(); const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); @@ -307,28 +322,33 @@ describe('WorkspaceRuntimeCoordinator', () => { expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledOnce(); }); - it('waits for live MCP status from the current runtime epoch', async () => { - const harness = makeRuntime(); - harness.getWorkspaceMcpStatus.mockResolvedValueOnce({ - v: 1, - workspaceCwd: '/workspace', - initialized: true, - runtimeEpoch: 0, - source: 'cache', - discoveryState: 'completed', - servers: [], - }); + it.each([ + { runtimeEpoch: 1, source: 'cache' as const }, + { runtimeEpoch: 0, source: 'live' as const }, + ])( + 'waits for live MCP status after $source epoch $runtimeEpoch', + async (stale) => { + const harness = makeRuntime(); + harness.getWorkspaceMcpStatus.mockResolvedValueOnce({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + ...stale, + discoveryState: 'completed', + servers: [], + }); - const result = await getWorkspaceRuntimeCoordinator( - harness.runtime, - ).ensure(); + const result = await getWorkspaceRuntimeCoordinator( + harness.runtime, + ).ensure(); - expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledTimes(2); - expect(result.capabilities?.mcp).toMatchObject({ - state: 'ready', - runtimeEpoch: 1, - }); - }); + expect(harness.getWorkspaceMcpStatus).toHaveBeenCalledTimes(2); + expect(result.capabilities?.mcp).toMatchObject({ + state: 'ready', + runtimeEpoch: 1, + }); + }, + ); it('waits for the latest MCP revision when ensure overlaps a config change', async () => { const harness = makeRuntime(); @@ -420,6 +440,40 @@ describe('WorkspaceRuntimeCoordinator', () => { }); }); + it('repairs a queued MCP mutation rejected by a drain race', async () => { + const harness = makeRuntime(); + const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); + await coordinator.ensure(); + let releaseMutation!: () => void; + const mutationGate = new Promise((resolve) => { + releaseMutation = resolve; + }); + const mutationStarted = vi.fn(); + const first = coordinator.runMcpRuntimeMutation(async () => { + mutationStarted(); + await mutationGate; + return { accepted: true }; + }); + await vi.waitFor(() => expect(mutationStarted).toHaveBeenCalledOnce()); + const queued = coordinator.runMcpRuntimeMutation(async () => ({ + accepted: true, + })); + const queuedRejected = await expect(queued).rejects.toBeInstanceOf( + WorkspaceDrainingError, + ); + + coordinator.beginDrain(); + releaseMutation(); + await first; + await queuedRejected; + coordinator.cancelDrain(); + + await vi.waitFor(() => { + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp?.state).toBe('ready'); + }); + }); + it('replays MCP reconciliation interrupted while draining', async () => { const harness = makeRuntime(); const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); diff --git a/packages/cli/src/serve/workspace-runtime-coordinator.ts b/packages/cli/src/serve/workspace-runtime-coordinator.ts index c0759240496..1987d255bad 100644 --- a/packages/cli/src/serve/workspace-runtime-coordinator.ts +++ b/packages/cli/src/serve/workspace-runtime-coordinator.ts @@ -231,12 +231,20 @@ export class WorkspaceRuntimeCoordinator { revision, ...(initial.runtimeLive ? { runtimeEpoch: initial.runtimeEpoch } : {}), }; - return await this.queueMcpWork(async () => { + let started = false; + const operation = this.queueMcpWork(async () => { + started = true; let snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); let mutationRejected = false; try { if (!snapshot.runtimeLive) { - await this.bridge.preheat({ keepAliveMs: ENSURE_KEEP_ALIVE_MS }); + try { + await this.bridge.preheat({ keepAliveMs: ENSURE_KEEP_ALIVE_MS }); + } catch (error) { + if (error instanceof WorkspaceRuntimeStillStartingError) + throw error; + throw new WorkspaceRuntimeInitializationError(error); + } snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); if (!snapshot.runtimeLive) { throw new WorkspaceRuntimeInitializationError( @@ -276,6 +284,15 @@ export class WorkspaceRuntimeCoordinator { throw error; } }); + try { + return await operation; + } catch (error) { + if (!started) { + if (this.draining && !this.disposed) this.mcpReconcileDeferred = true; + this.recordMcpError(revision, initial.runtimeEpoch, error); + } + throw error; + } } private prepareMcp(): Promise { @@ -305,7 +322,12 @@ export class WorkspaceRuntimeCoordinator { const deadline = Date.now() + MCP_PREPARE_TIMEOUT_MS; let snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); if (!snapshot.runtimeLive) { - await this.bridge.preheat({ keepAliveMs: ENSURE_KEEP_ALIVE_MS }); + try { + await this.bridge.preheat({ keepAliveMs: ENSURE_KEEP_ALIVE_MS }); + } catch (error) { + if (error instanceof WorkspaceRuntimeStillStartingError) throw error; + throw new WorkspaceRuntimeInitializationError(error); + } snapshot = this.bridge.getWorkspaceRuntimeLifecycleSnapshot(); } const epoch = snapshot.runtimeEpoch; diff --git a/packages/web-shell/client/components/messages/McpStatusMessage.tsx b/packages/web-shell/client/components/messages/McpStatusMessage.tsx index e3f30a44519..f351899af12 100644 --- a/packages/web-shell/client/components/messages/McpStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/McpStatusMessage.tsx @@ -532,7 +532,11 @@ export function McpStatusMessage({ : details; } } - await reloadSelectedServer(); + try { + await reloadSelectedServer(); + } catch (error) { + if (!nextActionMessage) throw error; + } setActionMessage( nextActionMessage ?? t('mcp.action.done', { action: action.label }), ); diff --git a/packages/web-shell/client/daemon/workspace/actions.test.ts b/packages/web-shell/client/daemon/workspace/actions.test.ts index 417e22f89aa..ea64ba3fc70 100644 --- a/packages/web-shell/client/daemon/workspace/actions.test.ts +++ b/packages/web-shell/client/daemon/workspace/actions.test.ts @@ -434,6 +434,34 @@ describe('workspace actions', () => { ); }); + it('disables a user MCP server in user scope', async () => { + const setUserMcpServerEnabled = vi + .fn() + .mockResolvedValue({ changed: true, activation: 'reconciling' }); + const setMcpServerEnabled = vi.fn(); + const workspaceByCwd = vi.fn(() => ({ + mcpConfig: vi.fn().mockResolvedValue({ + user: { docs: { command: 'docs' } }, + workspace: {}, + }), + setMcpServerEnabled, + })); + const actions = createDaemonWorkspaceActions({ + getClient: () => + ({ + setUserMcpServerEnabled, + workspaceByCwd, + }) as unknown as DaemonClient, + getWorkspaceCwd: () => '/secondary', + baseUrl: 'http://daemon', + }); + + await actions.manageMcpServer('docs', 'disable'); + + expect(setUserMcpServerEnabled).toHaveBeenCalledWith('docs', false); + expect(setMcpServerEnabled).not.toHaveBeenCalled(); + }); + it('forwards an extension interaction response to the daemon client', async () => { const respondToExtensionInteraction = vi .fn() diff --git a/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx index 456d6900e3b..e31dd1e2de9 100644 --- a/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx +++ b/packages/web-shell/client/daemon/workspace/hooks/useDaemonMcp.test.tsx @@ -107,9 +107,12 @@ describe('useDaemonMcp', () => { expect(current?.loadTools).toBe(first?.loadTools); }); - it('does not treat prototype properties as user config servers', async () => { - workspaceClient.mcpConfig.mockResolvedValue({ user: {}, workspace: {} }); - workspaceClient.setMcpServerEnabled.mockResolvedValue({ changed: true }); + it('uses own properties when selecting a config scope', async () => { + workspaceClient.mcpConfig.mockResolvedValue({ + user: { constructor: { command: 'user-server' } }, + workspace: {}, + }); + client.setUserMcpServerEnabled.mockResolvedValue({ changed: true }); let current: ReturnType | undefined; function TestComponent() { current = useDaemonMcp({ @@ -121,10 +124,10 @@ describe('useDaemonMcp', () => { await act(async () => root.render(() as ReactNode)); await current?.manageServer('constructor', 'disable'); - expect(client.setUserMcpServerEnabled).not.toHaveBeenCalled(); - expect(workspaceClient.setMcpServerEnabled).toHaveBeenCalledWith( + expect(client.setUserMcpServerEnabled).toHaveBeenCalledWith( 'constructor', false, ); + expect(workspaceClient.setMcpServerEnabled).not.toHaveBeenCalled(); }); }); From 3b936af2d75be78762578f5ab0f83d8ef3b3a352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Wed, 2 Sep 2026 07:47:56 +0800 Subject: [PATCH 5/5] test(serve): stabilize MCP drain race coverage --- .../workspace-runtime-coordinator.test.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/serve/workspace-runtime-coordinator.test.ts b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts index ecded8bb665..20f7b2a36b9 100644 --- a/packages/cli/src/serve/workspace-runtime-coordinator.test.ts +++ b/packages/cli/src/serve/workspace-runtime-coordinator.test.ts @@ -442,8 +442,8 @@ describe('WorkspaceRuntimeCoordinator', () => { it('repairs a queued MCP mutation rejected by a drain race', async () => { const harness = makeRuntime(); + harness.setSnapshot({ state: 'idle', runtimeLive: true, runtimeEpoch: 1 }); const coordinator = getWorkspaceRuntimeCoordinator(harness.runtime); - await coordinator.ensure(); let releaseMutation!: () => void; const mutationGate = new Promise((resolve) => { releaseMutation = resolve; @@ -454,24 +454,30 @@ describe('WorkspaceRuntimeCoordinator', () => { await mutationGate; return { accepted: true }; }); - await vi.waitFor(() => expect(mutationStarted).toHaveBeenCalledOnce()); + await Promise.resolve(); + await Promise.resolve(); + expect(mutationStarted).toHaveBeenCalledOnce(); const queued = coordinator.runMcpRuntimeMutation(async () => ({ accepted: true, })); - const queuedRejected = await expect(queued).rejects.toBeInstanceOf( - WorkspaceDrainingError, - ); + let queuedError: unknown; + const queuedHandled = queued.catch((error: unknown) => { + queuedError = error; + }); coordinator.beginDrain(); releaseMutation(); - await first; - await queuedRejected; + await queuedHandled; + expect(queuedError).toBeInstanceOf(WorkspaceDrainingError); + expect(coordinator.status().capabilities?.mcp?.state).toBe('stale'); coordinator.cancelDrain(); + await first; - await vi.waitFor(() => { - expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); - expect(coordinator.status().capabilities?.mcp?.state).toBe('ready'); - }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.reloadWorkspaceMcp).toHaveBeenCalledOnce(); + expect(coordinator.status().capabilities?.mcp?.state).toBe('ready'); }); it('replays MCP reconciliation interrupted while draining', async () => {