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 4c9f5eefb72..ac4c1015473 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 5915e1ba290..8a1d4c61c42 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 = 5min`, `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 = 5 * 60 * 1000` keeps a wedged permission request from blocking the per-session `promptQueue` forever. - `DEFAULT_MAX_PENDING_PER_SESSION = 64` mirrors `DEFAULT_MAX_SUBSCRIBERS`; excess `requestPermission` calls resolve as cancelled with a stderr warning. @@ -215,7 +215,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 7374e25e54a..ac5fa0701b0 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 ce9016fa4d5..b2c96849683 100644 --- a/docs/developers/daemon/08-session-lifecycle.md +++ b/docs/developers/daemon/08-session-lifecycle.md @@ -300,19 +300,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 9427873b85d..ee938ba4b0c 100644 --- a/docs/developers/daemon/17-configuration.md +++ b/docs/developers/daemon/17-configuration.md @@ -35,8 +35,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`; every API route stays token-gated. `--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. | | `--session-reap-interval-ms ` | non-negative integer | `60000` | Session reaper scan interval; `0` disables it. | | `--session-idle-timeout-ms ` | non-negative integer | `1800000` | Disconnected-session idle reaping time; `0` disables it. | @@ -122,8 +122,8 @@ The daemon constructs each workspace runtime from that workspace's merged settin | `enableSessionShell` | Enables session shell execution; bearer token and 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. | -| `initializeTimeoutMs` | ACP child request timeout, including the initialize handshake. | +| `channelIdleTimeoutMs` | ACP child auto-reap delay after runtime work drains; unset or `0` reaps immediately. | +| `initializeTimeoutMs` | ACP child startup deadline (channel factory + initialize handshake) and default request timeout. | | `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. | | `sessionIdleTimeoutMs` | Disconnected-session idle reaping time. | @@ -145,7 +145,7 @@ The daemon constructs each workspace runtime from that workspace's merged settin | `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. | | `contextFilename` | Overrides `getCurrentGeminiMdFilename()`. | -| `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 a8c935e8447..ed57e6baa42 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 @@ -89,15 +89,15 @@ The CLI is defined in **`packages/cli/src/commands/serve.ts`**: | `--require-auth` | boolean | `false` | Token required | Extends bearer auth to loopback **and** `/health`. Boot refuses to start without a token. | | `--enable-session-shell` | boolean | `false` | Token required | Enables direct `POST /session/:id/shell` execution. Callers must also send a session-bound `X-Qwen-Client-Id`. | | `--event-ring-size ` | number | `8000` | - | Per-session SSE replay ring depth. Soft cap is `MAX_EVENT_RING_SIZE = 1_000_000`; out-of-range values throw during bridge construction. | -| `--http-bridge` | boolean | `true` | - | Bridge mode: production attempts to preheat one primary `qwen --acp` child and retries on first use after failure; trusted secondaries start one on demand, while untrusted secondaries cannot start ACP. Stage 2 in-process mode is not implemented yet; `--no-http-bridge` falls back and prints to stderr. | +| `--http-bridge` | boolean | `true` | - | Bridge mode: production attempts to preheat the trusted primary `qwen --acp` child; trusted secondaries start on their first runtime command or Session, and untrusted workspaces cannot start ACP. Stage 2 in-process mode is not implemented yet; `--no-http-bridge` falls back and prints to stderr. | | `--mcp-client-budget ` | number | none | Required for `mcp-budget-mode=enforce` | Workspace MCP client cap. Must be a positive integer. | | `--mcp-budget-mode ` | `'enforce' \| 'warn' \| 'off'` | `warn` when a budget is set, otherwise `off` | `enforce` requires `--mcp-client-budget` | `enforce` refuses, `warn` only warns at 75%, `off` is observation only. | | `--allow-origin ` | repeatable string | none | - | CORS allowlist that replaces the default Origin denial. `*` requires a token. | | `--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 9835f423235..fffd021e083 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -207,7 +207,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', 'workspace_persisted_transcript', @@ -253,6 +253,28 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design `workspace_qualified_rest_core` advertises plural core REST routes under `/workspaces/:workspace/...`. The selector resolves as exact workspace id first, then as a URL-encoded absolute cwd after canonicalization. Newer single-workspace daemons include the primary runtime in `workspaces[]` even when `multi_workspace_sessions` is absent, allowing clients to discover the id required by workspace-qualified routes; clients should fall back to `capabilities.workspaceCwd` for older daemons that omit the array. Trust status and trust request routes are available for registered untrusted workspaces; file read routes follow the existing filesystem read policy. Registered untrusted secondary workspaces also expose persisted-only session and session-group catalogs: these reads do not attach to a session, start ACP, or merge live bridge state. File writes, catalog mutations, and other plural core routes require a trusted workspace unless a separate capability explicitly defines a narrower read-only policy, such as `workspace_persisted_transcript`. An untrusted primary continues to receive `403 { code: "untrusted_workspace" }` from the plural catalog and transcript routes; legacy singular primary routes keep their existing compatibility behavior. This tag covers the core file, status, settings, permissions, trust, lifecycle, MCP control, tool and skill toggles, memory, workspace agent CRUD, and session storage surfaces. It does not cover auth, voice, extensions, ACP/WebSocket transport, channel-worker routing, or workspace-qualified session export; pre-flight `workspace_session_export` or `workspace_archived_session_export` separately. Workspace trust is not an ACL: a client holding the daemon token can read every registered workspace surface allowed by this policy. +`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 +foundation response does not claim feature-specific catalog readiness. A +successful `ensure` renews a workspace-level ten-minute warm window, so every +call extends the reuse deadline and follow-up status and catalog reads can +observe the initialized child. The HTTP observer waits up to 60 seconds; a +`503 runtime_still_starting` does not cancel the bounded physical startup, and +clients may poll status or call ensure again. A physical startup failure returns +`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. + +`ensure` uses the ordinary daemon mutation admission, matching Session +creation: a loopback daemon without a configured token accepts it, while +configured-token, `--require-auth`, and non-loopback deployments still enforce +bearer authentication. It does not use the stricter “token must be configured +even on loopback” gate reserved for sensitive configuration mutations. + `workspace_qualified_voice` advertises Voice routes selected by a trusted workspace runtime: `GET` and `POST /workspaces/:workspace/voice`, `POST /workspaces/:workspace/voice/transcribe`, and `WS /workspaces/:workspace/voice/stream`. It is advertised only when multi-workspace runtimes and the shared ACP/Voice WebSocket listener are both enabled. The selector follows the same id-or-encoded-absolute-cwd rules as other plural routes. For REST, an unknown selector returns `400 { code: "workspace_mismatch" }` and an untrusted selector returns `403 { code: "untrusted_workspace" }`; WebSocket upgrade rejection exposes the corresponding HTTP 400/403 status without a structured JSON envelope. Neither transport falls back to primary. Legacy `/workspace/voice`, `/workspace/voice/transcribe`, and `/voice/stream` remain primary-only. Clients use `workspace_qualified_voice` for all qualified Voice modalities and let the selected runtime report configuration-specific errors. The legacy `workspace_voice`, `workspace_voice_transcription`, and `voice_transcribe` tags describe only the primary-bound routes and must not hide a qualified secondary configuration. `workspace_qualified_memory` advertises the workspace-qualified managed-memory routes: `POST /workspaces/:workspace/memory/{remember,forget,dream}` enqueue tasks and `GET /workspaces/:workspace/memory/{remember,forget,dream}/:taskId` reads them back. It is advertised only when ACP HTTP and multi-workspace runtimes are both enabled. The selector follows the same id-or-encoded-absolute-cwd rules as other plural routes. Each registered workspace gets its own task lane; the primary's qualified lane is the same instance as the singular `/workspace/memory` surface, so a task enqueued on one is readable on the other. Resolution is strictly per selected runtime with no primary fallback: an unknown selector returns `400 { code: "workspace_mismatch" }`, an untrusted selector returns `403 { code: "untrusted_workspace" }`, and an inactive or draining runtime returns `503 { code: "workspace_runtime_unavailable" }`. Reads never allocate a lane, so polling a workspace that has no tasks returns `404 { code: "_task_not_found" }`. Task ids are scoped to their lane and do not survive a workspace reconfiguration or runtime replacement; a stale id returns `404`, not a data-loss condition. When ACP HTTP is disabled the tag is not advertised and a non-primary qualified request returns a non-retryable `501 { code: "workspace_memory_unavailable" }`, while the primary qualified route keeps working through the locally-owned lane. @@ -471,6 +493,7 @@ operator diagnostic snapshot documented below. | `persistent_workspace_registration` | a workspace registration store is wired into the daemon. Production `runQwenServe` supplies the user-level store automatically; direct `createServeApp` embeds must inject one explicitly and own startup restoration of their workspace registry. | | `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. | +| `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 1f200c91d32..2c2d5c3b7cf 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/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 76ab3a84e38..7c9eb1530d2 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -11,7 +11,7 @@ Run Qwen Code as a local HTTP daemon so multiple clients (IDE plugins, web UIs, ## What it gives you - **Built-in Web Shell UI** — `qwen serve` serves the browser-based Web Shell at its root (`http://127.0.0.1:4170/`) out of the box; run `qwen serve --open` to launch it in your browser automatically. It is served on the same origin as the API, so no second port or reverse proxy is needed. Pass `--no-web` for an API-only daemon. -- **Up to one primary ACP child plus one on-demand child per trusted secondary, many clients** — production attempts to preheat the primary bridge and retries on first use after failure; trusted secondary runtimes start their own child on demand, while untrusted secondaries never start one. Under the default `sessionScope: 'single'`, clients targeting the same workspace share one ACP session and collaborate on the same conversation, file diffs, and permission prompts. +- **Up to one ACP child per trusted workspace, many clients** — production attempts to preheat the primary child for compatibility; trusted secondaries start on their first runtime-backed request, and untrusted workspaces never start one. Under the default `sessionScope: 'single'`, clients targeting the same workspace share one ACP session and collaborate on the same conversation, file diffs, and permission prompts. - **Reconnect-safe streaming** — SSE with `Last-Event-ID` reconnect lets a client drop and pick up exactly where it left off (within the ring's replay window). - **Paged persisted transcripts** — `GET /session/:id/transcript` returns the complete active on-disk transcript as replay pages without attaching a client or changing the live SSE replay window. - **First-responder permissions** — when the agent asks for permission to run a tool, every connected client sees the request; whichever client answers first wins. @@ -408,7 +408,7 @@ Notes: | `--external-tool-guard-mode ` | `off` | Managed ACP external pre-execution policy. `off` makes no provider calls and advertises no capability. `required` fails startup unless a compatible provider completes the v1 handshake, then fails every supported top-level tool invocation closed unless its single prepare request is allowed. | | `--external-tool-guard-endpoint ` | — | Origin-only loopback HTTP(S) provider URL used in `required` mode, for example `http://127.0.0.1:8787`. Paths, URL credentials, redirects, non-loopback hosts, and proxy routing are not accepted. | | `--external-tool-guard-timeout-ms ` | `3000` | Integer `100..30000`; applies independently to the startup handshake and each prepare request. | -| `--http-bridge` | `true` | Stage 1 mode: production attempts to preheat one primary `qwen --acp` child for compatibility and retries on first use after failure, while each trusted secondary can start one child on demand. Sessions targeting a runtime multiplex onto its child via ACP `newSession()`; untrusted secondaries cannot start ACP. Stage 2 native in-process becomes available later. | +| `--http-bridge` | `true` | Stage 1 mode: production attempts to preheat the trusted primary `qwen --acp` child and retries on first use after failure; trusted secondaries start on their first runtime-backed command or Session. Sessions targeting a runtime multiplex onto its child via ACP `newSession()`; untrusted workspaces cannot start ACP. Stage 2 native in-process becomes available later. | | `--initialize-timeout-ms ` | `10000` | ACP child request timeout, including the `initialize` handshake (ms). Must be a positive integer up to `2147483647`. Values above the JS timer ceiling (`2^31-1`) are rejected at boot because Node silently compresses them to 1 ms. Cold-container deployments that need extra headroom for child startup can raise this; the same value governs `newSession`, workspace-status polls, and other ACP ext-method deadlines. | | `--session-restore-timeout-ms ` | `60000` | ACP session load/resume deadline in milliseconds. Must be a positive integer up to `2147483647`; `0` is invalid. If omitted, the default is 60 seconds, raised to an explicitly supplied `--initialize-timeout-ms` when that value is larger; a shorter initialize timeout never lowers the restore budget. The SDK and WebUI add 10 and 15 seconds of client headroom. A timeout returns retryable `504 session_restore_timeout`; it does not imply that the daemon itself exited. | | `--allow-origin ` | — | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). Cross-origin allowlist for browser webui clients. Repeatable. Each value is `*` (any origin — boot refuses if no bearer token is configured; `--require-auth` on loopback is recommended so `/health` is also bearer-gated, since it is pre-auth on loopback by default; the Web Shell static assets stay pre-auth in every mode, so pass `--no-web` to remove them) or a canonical URL origin (`://[:]`, no trailing slash / path / userinfo / query). **Subdomain wildcards (`https://*.example.com`) are intentionally unsupported** — list each subdomain explicitly, or use `*` with a configured token (and `--require-auth` for full hardening). Matched origins receive CORS response headers (`Access-Control-Allow-Origin`, `Vary: Origin`, methods, headers, max-age, and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as today's wall. `Origin: null` (sandboxed iframes, file:// docs) is always rejected, even under `*`. Pre-flight via `caps.features.allow_origin`. Loopback self-origin hits are unaffected. | @@ -604,7 +604,7 @@ Both flags accept a positive integer in milliseconds; `0`, `NaN`, non-integer, o ## Multi-session & multi-workspace deployment -Pass `--workspace` more than once to register several non-overlapping workspaces in one `qwen serve` process. The first path is primary. Each registered workspace owns an isolated runtime boundary, while the daemon-wide listener, authentication policy, and total-session limit are shared. Production attempts to preheat the primary ACP child for compatibility and retries on first use after failure; trusted secondaries start their own child on demand, and untrusted secondaries do not start ACP. Requests may select a registered workspace by canonical `cwd`; requests that omit `cwd` use the primary workspace. Use one daemon per user or security principal; workspace trust is an execution gate, not an ACL. +Pass `--workspace` more than once to register several non-overlapping workspaces in one `qwen serve` process. The first path is primary. Each registered workspace owns an isolated runtime boundary, while the daemon-wide listener, authentication policy, and total-session limit are shared. Production attempts to preheat the trusted primary ACP child for compatibility; trusted secondaries start on first runtime-backed use, and untrusted workspaces do not start ACP. Requests may select a registered workspace by canonical `cwd`; requests that omit `cwd` use the primary workspace. Use one daemon per user or security principal; workspace trust is an execution gate, not an ACL. An untrusted secondary workspace is visible in Web Shell as `untrusted` and `read-only`. It can be expanded to inspect the persisted session catalog, but it cannot yet be selected or opened in Web Shell, resumed, used to create sessions, or fully exported. The REST API follows the existing bounded filesystem read policy and also exposes its persisted session-group catalog and, when `workspace_persisted_transcript` is advertised, its active persisted transcript through the bounded workspace-qualified pager. These reads do not include live runtime state or start an ACP child. Full workspace-qualified export requires a trusted workspace and the separate `workspace_session_export` capability. Trust the workspace and restart the daemon before using execution, mutation, or export features. An untrusted primary remains disabled in Web Shell. @@ -663,7 +663,7 @@ For an archived attachment, pre-flight `workspace_archived_session_export` and c `limit` counts active chat records, not emitted replay frames; one record can produce several `session_update` events. The first response freezes the JSONL snapshot size and returns `nextCursor` while `hasMore` is true. Later pages ignore appends after page 1, but return `409` if the file is deleted, truncated, replaced, archived, or otherwise conflicts with the frozen cursor. Very large snapshots return `413 transcript_too_large` before indexing so the daemon does not scan unbounded transcript files on the request path. -For repeated paging through the legacy singular route, set `--channel-idle-timeout-ms` to a positive value. With the default `0`, an idle workspace's ACP child — and the in-process transcript index cache it holds — is reaped after every page, so each page re-spawns the child and rebuilds the index by re-scanning the whole frozen prefix (`O(snapshotSize)` per page). A positive timeout keeps the child alive across the cursor walk so it reuses its cached transcript index and replay config. The workspace-qualified persisted route never starts an ACP child and is unaffected by this timeout. +Repeated paging through the legacy singular route can reuse the Workspace Runtime and its in-process transcript index cache when a positive `--channel-idle-timeout-ms` is configured long enough to cover the cursor walk. By default, and with explicit `0`, the child is reaped as soon as runtime work drains and may restart between page requests. The workspace-qualified persisted route never starts an ACP child and is unaffected by this timeout. Note: live-session history replay is bounded twice: by the SSE ring for `Last-Event-ID` reconnects and by `--compacted-replay-max-bytes` for the snapshot returned by `POST /session/:id/load`. Long histories with chatty turns can exceed either bound. The daemon surfaces snapshot truncation with `history_truncated`; use `/transcript` when you need the complete active persisted history. @@ -741,7 +741,7 @@ In this mode, TUI is a **"super-client"** — it observes the same agent convers ### N parallel sessions share one `qwen --acp` child per workspace runtime -Multiple sessions on the same trusted workspace **share that runtime's `qwen --acp` child process** via the agent's native multi-session support (`packages/cli/src/acp-integration/acpAgent.ts:194: private sessions: Map`). The bridge calls `connection.newSession({cwd, mcpServers})` for each session — the agent stores them in its sessions map and demultiplexes per-call sessionId. Production can own up to one primary child (preheat attempted by default) plus one on-demand child per trusted secondary; untrusted secondaries own none. +Multiple sessions on the same trusted workspace **share that runtime's `qwen --acp` child process** via the agent's native multi-session support (`packages/cli/src/acp-integration/acpAgent.ts:194: private sessions: Map`). The bridge calls `connection.newSession({cwd, mcpServers})` for each session — the agent stores them in its sessions map and demultiplexes per-call sessionId. Production can own up to one child per trusted workspace: it attempts to preheat primary for compatibility, while trusted secondaries start on demand; untrusted workspaces own none. Concrete cost at N=5 sessions on the same workspace: @@ -756,7 +756,7 @@ Concrete cost at N=5 sessions on the same workspace: | Auto-memory learned facts | shared | one knowledge base per child | | Cold start | first only | <200 ms after first session | -Each active workspace runtime keeps **one bridge boundary**. Production attempts to preheat the primary channel and retries on first use after failure; a trusted secondary opens its channel and child on demand, while an untrusted secondary never does. A channel stays alive while at least one session is live. After the last `killSession`, the runtime kills its child immediately by default or after the configured channel idle grace; a channel-level crash also tears it down without selecting another runtime. +Each active workspace runtime keeps **one bridge boundary**. Production attempts to preheat the trusted primary channel for compatibility; trusted secondaries open their channel on the first runtime-backed command or Session, and an untrusted workspace never does. By default, and with explicit `0`, the child is reaped immediately after the last Session or management operation finishes and all physical leases drain. A successful explicit runtime `ensure` overrides that default with a renewable ten-minute warm window so follow-up management status and catalog reads reuse the initialized child; every successful call resets the window, even when the child is already live. A positive `--channel-idle-timeout-ms` can provide a longer general idle window. Workspace removal, daemon shutdown, explicit restart, or a channel-level crash also tears it down without selecting another runtime. **MCP server children** use the workspace-scoped transport pool when `mcp_workspace_pool` is advertised: matching `(workspace runtime, server name, config fingerprint)` entries are refcounted across sessions. If the capability is absent, the legacy per-session manager independently spawns them. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 90402f57587..afebc616064 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -400,6 +400,7 @@ describe('qwen serve — capabilities envelope', () => { 'persistent_workspace_registration', 'workspace_display_name', 'workspace_runtime_removal', + 'workspace_runtime', 'workspace_qualified_rest_core', 'extension_management_v2', 'workspace_persisted_transcript', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index be0de381cb7..1af2ef2d4c3 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -64,6 +64,7 @@ import { import type { ChannelFactory } from './channel.js'; import type { BridgeFreshSessionAdmissionContext, + BridgeRuntimeEpochSource, BridgeTelemetry, } from './bridgeOptions.js'; import { createInMemoryChannel } from './inMemoryChannel.js'; @@ -3290,14 +3291,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 }); @@ -3306,31 +3312,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(); @@ -5973,6 +5981,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 () => { @@ -6052,7 +6082,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({ @@ -6063,21 +6094,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 @@ -19419,6 +19460,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 } = {}; @@ -21889,37 +21987,1113 @@ 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.each([ + { + label: 'session branch', + method: SERVE_CONTROL_EXT_METHODS.sessionBranch, + timeoutMs: 50, + invoke: (bridge: ReturnType, sessionId: string) => + bridge.branchSession(sessionId, { name: 'stalled-branch' }), + }, + { + label: 'session cwd change', + method: SERVE_CONTROL_EXT_METHODS.sessionCd, + timeoutMs: 30_000, + invoke: (bridge: ReturnType, sessionId: string) => + bridge.changeSessionCwd(sessionId, { path: '/next' }), + }, + ])( + 'retires the channel when a $label RPC exceeds its physical deadline', + async ({ method, timeoutMs, invoke }) => { + vi.useFakeTimers(); + 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, + }); + + try { + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + const request = invoke(bridge, session.sessionId); + 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); + await vi.waitFor(() => 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 { + stalledResult.resolve({}); + await bridge.shutdown(); + vi.useRealTimers(); + } + }, + ); + + it.each([ + { + label: 'session branch', + method: SERVE_CONTROL_EXT_METHODS.sessionBranch, + invoke: (bridge: ReturnType, sessionId: string) => + bridge.branchSession(sessionId, { name: 'queued-branch' }), + }, + { + label: 'session cwd change', + method: SERVE_CONTROL_EXT_METHODS.sessionCd, + invoke: (bridge: ReturnType, sessionId: string) => + bridge.changeSessionCwd(sessionId, { path: '/next' }), + }, + ])( + 'does not run a queued $label against a replacement runtime epoch', + async ({ method, invoke }) => { + const promptStarted = deferred(); + const promptResult = deferred(); + const firstHandle = makeChannel({ + promptImpl: () => { + promptStarted.resolve(); + return promptResult.promise; + }, + }); + const secondHandle = makeChannel(); + const channelFactory = vi + .fn() + .mockResolvedValueOnce(firstHandle.channel) + .mockResolvedValueOnce(secondHandle.channel); + const bridge = makeBridge({ channelFactory }); + + try { + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }); + const prompt = bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'hold the queue' }], + }); + void prompt.catch(() => undefined); + await promptStarted.promise; + + const queued = invoke(bridge, session.sessionId); + firstHandle.crash(); + + await expect(prompt).rejects.toBeInstanceOf(BridgeChannelClosedError); + await expect(queued).rejects.toBeInstanceOf(SessionNotFoundError); + expect(channelFactory).toHaveBeenCalledTimes(1); + expect(secondHandle.agent.extMethodCalls).not.toEqual( + expect.arrayContaining([expect.objectContaining({ method })]), + ); + } finally { + promptResult.resolve({ stopReason: 'end_turn' }); + await bridge.shutdown(); + } + }, + ); + + it.each(['new session', 'load session', 'resume session'] as const)( + 'retires a shared channel when a timed-out %s RPC remains physically pending', + async (operation) => { + vi.useFakeTimers(); + const stalledStarted = deferred(); + const stalled = deferred>(); + let newSessionCalls = 0; + const firstHandle = makeChannel({ + newSessionImpl: async () => { + newSessionCalls++; + if (newSessionCalls === 1) { + return { sessionId: 'live-session' }; + } + stalledStarted.resolve(); + return (await stalled.promise) as NewSessionResponse; + }, + loadSessionImpl: async () => { + stalledStarted.resolve(); + return (await stalled.promise) as LoadSessionResponse; + }, + resumeSessionImpl: async () => { + stalledStarted.resolve(); + return (await stalled.promise) as ResumeSessionResponse; + }, + }); + 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 = + operation === 'new session' + ? bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sessionScope: 'thread', + }) + : operation === 'load session' + ? bridge.loadSession({ + sessionId: 'stalled-load', + workspaceCwd: WS_A, + }) + : bridge.resumeSession({ + sessionId: 'stalled-resume', + workspaceCwd: WS_A, + }); + void request.catch(() => undefined); + await stalledStarted.promise; + + await vi.advanceTimersByTimeAsync(50); + 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, + }); + } 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('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 6a715418b40..106a5a1c60a 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -779,8 +779,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). */ @@ -866,8 +868,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. * @@ -1999,6 +2004,17 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { `Invalid initializeTimeoutMs: ${initTimeoutMs}. Must be > 0.`, ); } + 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 @@ -2080,9 +2096,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 @@ -2092,6 +2110,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, @@ -2509,6 +2531,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, @@ -2527,24 +2568,47 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { context?: string, ): Promise { ci.isDying = true; - 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); @@ -2579,13 +2643,24 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ci.pendingRestoreIds.has(opts.ignoreRestoreId) ? 1 : 0); + const outerSpawnCount = + inFlightSpawns.size - (opts?.ignoreCurrentSessionSpawn === true ? 1 : 0); + const outerRestoreCount = + inFlightRestores.size - + (opts?.ignoreRestoreId !== undefined && + inFlightRestores.has(opts.ignoreRestoreId) + ? 1 + : 0); return ( ci.sessionIds.size === 0 && pendingRestoreCount === 0 && ci.workspaceControlInFlight === 0 && !ci.workspaceMcpDiscoveryInFlight && ci.workspaceMcpAuthenticationServerNames.size === 0 && - inFlightSpawnCount === 0 + inFlightSpawnCount === 0 && + runtimeOperationReservations === 0 && + outerSpawnCount <= 0 && + outerRestoreCount <= 0 ); } @@ -2599,11 +2674,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(); } @@ -2678,7 +2750,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { if (!channelShouldReapWhenIdle(ci) || !hasNoChannelWork(ci)) return; ci.emptyReapPending = false; ci.isDying = true; - await ci.channel.kill().catch(() => { + await terminateChannel(ci.channel, 'pending empty channel').catch(() => { /* best-effort — channel.exited handler still runs */ }); } @@ -2687,15 +2759,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'); } } @@ -3018,6 +3109,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. @@ -3121,6 +3236,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 @@ -3132,7 +3250,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', @@ -3140,11 +3260,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; @@ -3288,10 +3434,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { try { // Raw exit is successful teardown after the forced signal; kill() // supplies the bounded failure path when exit is never observed. - await Promise.race([ - channel.exited.then(() => undefined), - channel.kill(), - ]); + await terminateChannel(channel, 'channel construction failure'); } catch (teardownError) { throw new AggregateError( [error, teardownError], @@ -3509,6 +3652,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({ @@ -3534,7 +3681,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }), channelUnavailableReject(channel, 'during initialize'), ]), - initTimeoutMs, + remainingStartupMs, 'initialize', ); if (opts.externalToolGuard) { @@ -3597,7 +3744,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; } @@ -3612,15 +3762,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; telemetry.metrics?.channelLifecycle('spawn'); @@ -3689,8 +3870,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // while the ordinary post-close tombstone is still live. ci.client.markSessionRegistrationInFlight(requestedSessionId); } - let sessionRegistered = false; - let sessionRemovedDuringInitialization = false; let initializedSessionId: string | undefined; let newSessionResp: { sessionId: string; @@ -3752,11 +3931,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }, ); } catch (err) { + await retireChannelOnTimeout(ci, err, 'new session timeout'); // Only reap when this newSession was the channel's first/only // attempt — a populated channel keeps running for its other // live sessions. If other work is still using the empty channel, // arm a deferred reap so the last blocker tears it down. - if (hasNoChannelWork(ci, { ignoreCurrentSessionSpawn: true })) { + if ( + !ci.isDying && + hasNoChannelWork(ci, { ignoreCurrentSessionSpawn: true }) + ) { // Mark dying SYNCHRONOUSLY so a concurrent `spawnOrAttach` // calling `ensureChannel()` between this point and the // `channel.exited` cleanup spawns a fresh channel instead of @@ -3764,10 +3947,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // stays set until OS reap so `killAllSync` mid-SIGTERM still // finds a target (BkUyD invariant). ci.isDying = true; - await ci.channel.kill().catch(() => { + await terminateChannel(ci.channel, 'failed new session').catch(() => { /* best-effort — channel.exited handler still runs */ }); - } else { + } else if (!ci.isDying) { ci.emptyReapPending = true; } throw err; @@ -3796,7 +3979,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { { parentSessionId, sourceType, sourceId, worktree, branch }, ); initializedSessionId = entry.sessionId; - sessionRegistered = true; onSessionRegistered?.(); seedSnapshotCaches(entry, newSessionResp); const clientId = registerClient(entry, requestedClientId); @@ -3944,7 +4126,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { await closeSessionImpl(entry.sessionId, undefined, { reason: 'approval_mode_initialization_failed', }); - sessionRemovedDuringInitialization = true; } catch { /* best-effort; preserve the approval-mode failure */ } @@ -4001,22 +4182,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } } ci.sessionSpawnsInFlight = Math.max(0, ci.sessionSpawnsInFlight - 1); - if (!sessionRegistered) { - await reapPendingEmptyChannel(ci); - } else if (sessionRemovedDuringInitialization && hasNoChannelWork(ci)) { - await reapPendingEmptyChannel(ci); - if (!ci.isDying) { - await startIdleTimer( - ci, - `approval-mode initialization failure "${initializedSessionId}"`, - ); - } - } else if (sessionRegistered && hasNoChannelWork(ci) && !ci.isDying) { - await startIdleTimer( - ci, - `session orphaned during initialization "${initializedSessionId}"`, - ); - } + // The spawn tracker stays in `inFlightSpawns` until + // `spawnOrAttach`'s finally, so `hasNoChannelWork` can never + // return true here; idle arming/reaping settles there after the + // tracker is deleted. } } @@ -4405,11 +4574,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 => { @@ -4452,7 +4622,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. } }; @@ -4524,94 +4699,119 @@ 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'); + 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; } - } - 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, - ); + 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; } - } - if (authenticationCompleted) { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'workspace MCP authentication complete'); + 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 (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, + ): Promise => { + if (info.isDying || liveChannelInfo() !== info) return; + 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. } - return response as unknown as T; + if ( + info.isDying || + liveChannelInfo() !== info || + !info.workspaceMcpAuthenticationServerNames.has(serverName) + ) { + return; + } + await killChannelWithLog( + info, + `workspace MCP authentication timeout for ${serverName}`, + ); }; // Daemon Status child-resource: poll the live child's `workspaceResource` @@ -5397,9 +5597,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( @@ -5418,10 +5617,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { throw new SessionNotFoundError(req.sessionId); } throw err; - } finally { - if (hasNoChannelWork(info)) { - await startIdleTimer(info, 'session transcript'); - } } } @@ -6141,13 +6336,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; } @@ -6346,7 +6550,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; } @@ -6406,6 +6614,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; @@ -6552,15 +6764,26 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { entry.events.close(); 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 */ } @@ -6636,7 +6859,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { compactedReplayMaxBytes, maxJournalEvents, maxJournalBytes, - channelIdleTimeoutMs: resolvedChannelIdleTimeoutMs(), + channelIdleTimeoutMs: configuredChannelIdleTimeoutMs(), sessionIdleTimeoutMs, }, sessionCount: byId.size, @@ -6768,6 +6991,48 @@ 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 || + 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; }, @@ -7112,6 +7377,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // workspace (single-scope) or grow unbounded (thread-scope). inFlightSpawns.delete(tracker); releaseRequestedSessionRegistration(); + await settleReleasedRuntimeWork('session spawn'); } }, @@ -8048,6 +8314,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const branchResult = ( concurrentSideTask ? Promise.resolve() : entry.promptQueue ).then(async () => { + const ci = assertLivePromptEntry(sessionId, entry); if (entry.promptActive && !isSideTask) { throw new BranchWhilePromptActiveError(sessionId); } @@ -8071,25 +8338,26 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { admissionReleased = true; releaseFreshSessionReservation(admission); }; + runtimeOperationReservations++; try { - assertLivePromptEntry(sessionId, entry); - const ci = channelInfoForEntry(entry)!; - const result = (await withTimeout( - Promise.race([ - ci.connection.extMethod( - isSideTask - ? SERVE_CONTROL_EXT_METHODS.sessionSideTask - : SERVE_CONTROL_EXT_METHODS.sessionBranch, - { - sessionId, - cwd: boundWorkspace, - name: req.name, - }, - ), - getTransportClosedReject(entry), - ]), - initTimeoutMs, - isSideTask ? 'createSideTaskSession' : 'branchSession', + const result = (await withWorkspaceControl(ci, () => + withTimeout( + Promise.race([ + ci.connection.extMethod( + isSideTask + ? SERVE_CONTROL_EXT_METHODS.sessionSideTask + : SERVE_CONTROL_EXT_METHODS.sessionBranch, + { + sessionId, + cwd: boundWorkspace, + name: req.name, + }, + ), + getChannelClosedReject(ci), + ]), + initTimeoutMs, + isSideTask ? 'createSideTaskSession' : 'branchSession', + ), )) as { newSessionId: string; title?: string; displayName?: string }; if (!result || typeof result.newSessionId !== 'string') { @@ -8131,22 +8399,24 @@ 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, - }, - ), - 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, + }, + ), + channelUnavailableReject( + ci.channel, + 'during branchSession cleanup', + ), + ]), + initTimeoutMs, + 'branchSession cleanup', + ), ); } } catch (cleanupErr) { @@ -8215,6 +8485,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }; } finally { releaseAdmissionOnce(); + await releaseRuntimeOperationReservation('session branch'); } }); if (!concurrentSideTask) { @@ -8263,56 +8534,70 @@ 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 } - : {}), - }), - 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, () => + withTimeout( + Promise.race([ + ci.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionCd, { + sessionId, + path: req.path, + ...(req.allowedRoots + ? { allowedRoots: req.allowedRoots } + : {}), + ...(req.managedRelocation + ? { managedRelocation: req.managedRelocation } + : {}), + }), + getChannelClosedReject(ci), + ]), + Math.max(initTimeoutMs, 30_000), + 'changeSessionCwd', + ), ); - } + 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)}`, + ); + } - // State update inside the queue lambda — always executes when - // the extMethod settles, regardless of caller timeout. - entry.effectiveCwd = extResult.newCwd; - if (extResult.previousCwd !== extResult.newCwd) { - entry.events.publish({ - type: 'session_cwd_changed', - data: { - sessionId, - previousCwd: extResult.previousCwd, - newCwd: extResult.newCwd, - }, - ...(originatorClientId ? { originatorClientId } : {}), - }); + // State update stays inside the queue lambda so it runs when the + // extMethod settles before its physical deadline, even if the + // caller stopped waiting while this operation was queued. + entry.effectiveCwd = extResult.newCwd; + 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'); } - 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, @@ -8617,24 +8902,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' @@ -8652,115 +8929,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) { @@ -9082,7 +9333,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( @@ -9104,8 +9357,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: ` + @@ -10214,88 +10471,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); + }, 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( @@ -10311,17 +10553,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( @@ -10337,11 +10574,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) { @@ -10349,21 +10582,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) { @@ -10396,11 +10633,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; @@ -10451,9 +10687,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); @@ -10486,35 +10720,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) { @@ -10535,33 +10770,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( @@ -10896,7 +11132,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'), + ), ...inFlightSessionAwaits, ...inFlightRestoreAwaits, inFlightChannelAwait, @@ -10915,19 +11153,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 af4235f6859..40d2511b027 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -66,6 +66,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 @@ -192,6 +202,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; /** @@ -455,11 +467,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 a1b41316bc7..e7a51ce6aef 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -549,6 +549,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; @@ -1850,6 +1857,13 @@ export interface AcpSessionBridge { */ 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; @@ -1960,7 +1974,7 @@ export interface AcpSessionBridge { * 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 2fdb637ad3f..02f67c0db71 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 2b333644434..70c953430c9 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'; @@ -465,6 +465,39 @@ 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'); + + child.emit('exit', 0, null); + await expect(channel.exited).resolves.toEqual({ + exitCode: 0, + signalCode: null, + }); + 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 72970435340..c660a05b72e 100644 --- a/packages/acp-bridge/src/spawnChannel.ts +++ b/packages/acp-bridge/src/spawnChannel.ts @@ -427,7 +427,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) { @@ -482,6 +487,24 @@ export function createSpawnChannelFactory( throw error; } const trackedChild = reservation.attach(child); + 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 c88b3cfee35..b1896ec2d0f 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 bca5f3f4f08..bf8f5300e15 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -417,8 +417,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', { @@ -508,7 +508,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 0144b4c830a..9dc5ecfda21 100644 --- a/packages/cli/src/serve/acp-session-bridge.ts +++ b/packages/cli/src/serve/acp-session-bridge.ts @@ -66,6 +66,7 @@ export type { BridgeSessionLifecycle, BridgeSessionLifecycleEvent, BridgeOptions, + BridgeRuntimeEpochSource, DaemonStatusProvider, } from '@qwen-code/acp-bridge/bridgeOptions'; @@ -98,6 +99,7 @@ export type { BridgeDaemonStatusLimits, BridgeDaemonSessionDiagnostic, BridgeDaemonStatusSnapshot, + BridgeWorkspaceRuntimeLifecycleSnapshot, BridgeShutdownOptions, AcpSessionBridge, HttpAcpBridge, diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index d8eec7b13da..4143650fb23 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -344,6 +344,8 @@ export const SERVE_CAPABILITY_REGISTRY = { workspace_display_name: { since: 'v1' }, scratch_workspace_registration: { since: 'v1' }, workspace_runtime_removal: { 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/status/permissions/trust/lifecycle/MCP/tool, memory, // workspace agent CRUD, and persisted session organization surfaces. @@ -474,6 +476,7 @@ export interface AdvertiseFeatureToggles { persistentWorkspaceRegistrationAvailable?: boolean; scratchWorkspaceRegistrationAvailable?: boolean; workspaceRuntimeRemovalAvailable?: 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. @@ -603,6 +606,10 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< 'workspace_runtime_removal', (toggles) => toggles.workspaceRuntimeRemovalAvailable === 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 28c1ee05a22..00c3cb0c889 100644 --- a/packages/cli/src/serve/routes/workspace-management.test.ts +++ b/packages/cli/src/serve/routes/workspace-management.test.ts @@ -1699,6 +1699,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(); @@ -1847,6 +1875,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 28555ece554..1272acc992e 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 type { AcpHttpHandle } from '../acp-http/index.js'; import { isPortableAbsolutePath, @@ -83,6 +84,7 @@ export interface WorkspaceRemovalActivity { memoryTasks: number; channelWorkers: number; voiceSessions: number; + workspaceRuntime: number; } export interface WorkspaceRuntimeRemovalController { @@ -1126,6 +1128,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 => @@ -1336,6 +1343,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) { @@ -1354,6 +1364,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); @@ -1434,6 +1452,7 @@ export function registerWorkspaceManagementRoutes( registryDraining = false; controllerDraining = false; acpDraining = false; + runtimeCoordinatorDraining = false; }; try { @@ -1445,6 +1464,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 094332540e9..a31e9b1bf37 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -62,6 +62,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'; @@ -474,6 +475,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; +} + function writeWebShellFixture(workspaceDir: string): string { const shellDir = path.join(workspaceDir, 'web-shell'); fs.mkdirSync(path.join(shellDir, 'assets'), { recursive: true }); @@ -1457,6 +1470,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, @@ -1470,6 +1493,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-')), @@ -1999,6 +2093,7 @@ describe('runQwenServe telemetry validation', () => { maxSessions: 1, eventRingSize: 1234, compactedReplayMaxBytes: 1024, + channelIdleTimeoutMs: 60_000, sessionRestoreTimeoutMs: 90_000, serveWebShell: false, }, @@ -2020,6 +2115,7 @@ describe('runQwenServe telemetry validation', () => { }); } expect(createBridge.mock.calls[0]?.[0]).toMatchObject({ + channelIdleTimeoutMs: 60_000, compactedReplayMaxBytes: 1024, eventRingSize: 1234, sessionRestoreTimeoutMs: 90_000, @@ -2027,6 +2123,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, @@ -2053,6 +2150,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'); @@ -3549,7 +3675,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, ) @@ -3582,6 +3709,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(); } @@ -7188,6 +7323,7 @@ describe('runQwenServe runtime startup failures', () => { 'workspace_acp_status', 'persistent_workspace_registration', 'workspace_runtime_removal', + 'workspace_runtime', ]), modelServices: [], workspaceCwd: boundWorkspace, @@ -9608,6 +9744,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', @@ -9626,6 +9770,12 @@ describe('runQwenServe channel worker supervisor', () => { .mockImplementation((() => undefined) as never); const existingSigintListeners = new Set(process.rawListeners('SIGINT')); const existingSigtermListeners = new Set(process.rawListeners('SIGTERM')); + let workspaceRegistry: WorkspaceRegistry | undefined; + const originalCreateServeApp = serverModule.createServeApp; + vi.spyOn(serverModule, 'createServeApp').mockImplementation((...args) => { + workspaceRegistry = args[2]?.workspaceRegistry; + return originalCreateServeApp(...args); + }); await runQwenServe( { @@ -9643,6 +9793,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') @@ -9658,11 +9812,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); @@ -9808,9 +9971,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')) { @@ -11338,6 +11503,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 dad1fbe4830..f059aad63af 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -1150,6 +1150,7 @@ async function loadServeRuntimeModules() { workspaceSkillsStatusModule, totalSessionAdmissionModule, workspaceRegistryModule, + workspaceRuntimeCoordinatorModule, ] = await Promise.all([ import('./server.js'), import('@qwen-code/acp-bridge/bridge'), @@ -1162,6 +1163,7 @@ async function loadServeRuntimeModules() { import('./workspace-skills-status.js'), import('./total-session-admission.js'), import('./workspace-registry.js'), + import('./workspace-runtime-coordinator.js'), ]); return { createServeApp: serverModule.createServeApp, @@ -1191,6 +1193,8 @@ async function loadServeRuntimeModules() { workspaceRegistryModule.createWorkspaceSessionOwnerIndex, createWorkspaceGenerationGuard: workspaceRegistryModule.createWorkspaceGenerationGuard, + getWorkspaceRuntimeCoordinatorIfSupported: + workspaceRuntimeCoordinatorModule.getWorkspaceRuntimeCoordinatorIfSupported, }; } @@ -1224,6 +1228,7 @@ function currentServeFeaturesForRunQwenServe( opts: ServeOptions, sessionShellCommandEnabled: boolean, sessionArtifactsPersistenceAvailable: boolean, + workspaceRuntimeAvailable: boolean, env: Readonly>, ): string[] { return getAdvertisedServeFeatures(undefined, { @@ -1250,6 +1255,7 @@ function currentServeFeaturesForRunQwenServe( channelManagementAvailable: true, persistentWorkspaceRegistrationAvailable: true, workspaceRuntimeRemovalAvailable: true, + workspaceRuntimeAvailable, // Advertise the same WS feature flags as the runtime path (serve-features.ts) // so the bootstrap `/capabilities` window doesn't briefly under-report them. clientMcpOverWsEnabled: opts.clientMcpOverWs === true, @@ -1264,6 +1270,7 @@ function createBootstrapCapabilities(input: { qwenCodeVersion?: string; sessionShellCommandEnabled: boolean; sessionArtifactsPersistenceAvailable: boolean; + workspaceRuntimeAvailable: boolean; permissionPolicy: PermissionPolicy | undefined; env: Readonly>; }): CapabilitiesEnvelope { @@ -1278,6 +1285,7 @@ function createBootstrapCapabilities(input: { input.opts, input.sessionShellCommandEnabled, input.sessionArtifactsPersistenceAvailable, + input.workspaceRuntimeAvailable, input.env, ), modelServices: [], @@ -1471,6 +1479,7 @@ function createBootstrapServeApp(input: { qwenCodeVersion?: string; sessionShellCommandEnabled: boolean; sessionArtifactsPersistenceAvailable: boolean; + workspaceRuntimeAvailable: boolean; permissionPolicy: PermissionPolicy | undefined; multiWorkspaceCapabilitiesRequireRuntime: boolean; getRuntimeError: () => string | undefined; @@ -1489,6 +1498,7 @@ function createBootstrapServeApp(input: { qwenCodeVersion, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + workspaceRuntimeAvailable, permissionPolicy, multiWorkspaceCapabilitiesRequireRuntime, getRuntimeError, @@ -1554,6 +1564,7 @@ function createBootstrapServeApp(input: { qwenCodeVersion, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + workspaceRuntimeAvailable, permissionPolicy, env: process.env, }), @@ -1664,6 +1675,7 @@ function createBootstrapServeApp(input: { opts, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + workspaceRuntimeAvailable, process.env, ), }, @@ -2089,7 +2101,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), @@ -2987,6 +2999,9 @@ async function runQwenServeImpl( const webShellMounted = !!webShellDir; 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: | { @@ -3347,6 +3362,8 @@ async function runQwenServeImpl( cliVersionPromise, import('../config/daemon-trust-policy.js'), ]); + getWorkspaceRuntimeCoordinatorIfSupported = + runtime.getWorkspaceRuntimeCoordinatorIfSupported; cliVersion = resolvedCliVersion; settingsRuntime.environment.preResolveHomeEnvOverrides(); const bootTrustSnapshot = await trustPolicy.readDaemonTrustPolicySnapshot(); @@ -3357,6 +3374,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 && @@ -3784,6 +3805,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, @@ -4133,6 +4174,7 @@ async function runQwenServeImpl( ? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs } : {}), boundWorkspace, + runtimeEpochSource: runtimeEpochSourceFor(boundWorkspace), sessionShellCommandEnabled, childEnvOverrides, channelFactory, @@ -4537,6 +4579,7 @@ async function runQwenServeImpl( ? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs } : {}), boundWorkspace: workspaceInput.cwd, + runtimeEpochSource: runtimeEpochSourceFor(workspaceInput.cwd), sessionShellCommandEnabled, childEnvOverrides, channelFactory: secondaryChannelFactory, @@ -5091,6 +5134,7 @@ async function runQwenServeImpl( ? { permissionResponseTimeoutMs: opts.permissionResponseTimeoutMs } : {}), boundWorkspace: cwd, + runtimeEpochSource: runtimeEpochSourceFor(cwd), sessionShellCommandEnabled, childEnvOverrides, channelFactory: wsChannelFactory, @@ -5460,6 +5504,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( @@ -6087,6 +6134,9 @@ async function runQwenServeImpl( qwenCodeVersion: cliVersion, sessionShellCommandEnabled, sessionArtifactsPersistenceAvailable, + workspaceRuntimeAvailable: + deps.bridge === undefined || + typeof deps.bridge.getWorkspaceRuntimeLifecycleSnapshot === 'function', permissionPolicy, multiWorkspaceCapabilitiesRequireRuntime: workspaceInputs.length > 1, getRuntimeError: () => runtimeStartupError, @@ -7143,6 +7193,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; @@ -7339,6 +7402,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 e845e773045..778a7e4edb2 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -619,6 +619,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'workspace_display_name', 'scratch_workspace_registration', 'workspace_runtime_removal', + 'workspace_runtime', 'workspace_qualified_rest_core', 'workspace_qualified_voice', 'workspace_qualified_memory', @@ -2874,6 +2875,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, @@ -3719,6 +3734,7 @@ describe('createServeApp', () => { sessionArtifactsPersistenceAvailable: true, sessionGenerationAvailable: true, workspaceGenerationAvailable: true, + workspaceRuntimeAvailable: true, acpHttpEnabled: true, }), ); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index f98f6d955d5..6d34561dfc8 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -126,6 +126,10 @@ import { registerWorkspaceQualifiedStatusRoutes, registerWorkspaceStatusRoutes, } from './routes/workspace-status.js'; +import { + registerWorkspaceQualifiedRuntimeRoutes, + registerWorkspaceRuntimeRoutes, +} from './routes/workspace-runtime.js'; import { createDaemonWorkspaceService, type DaemonWorkspaceService, @@ -193,6 +197,7 @@ import { type WorkspaceRuntime, type WorkspaceRuntimeEnvMetadata, } from './workspace-registry.js'; +import { supportsWorkspaceRuntimeLifecycle } from './workspace-runtime-coordinator.js'; import { createWorkspaceRuntimeSessionService, runWithWorkspaceRuntimeStorage, @@ -939,6 +944,15 @@ export function createServeApp( acpHttpEnabled: acpHttpEnabledAtBoot, workspaceRuntimeRemovalAvailable: deps.workspaceRuntimeRemoval !== undefined, + workspaceRuntimeAvailable: () => { + const runtimes = workspaceRegistry.list(); + return ( + runtimes.length > 0 && + runtimes.every((runtime) => + supportsWorkspaceRuntimeLifecycle(runtime.bridge), + ) + ); + }, workspaceTrustHotReloadAvailable: deps.workspaceTrustHotReloadAvailable === true, isPrimaryWorkspaceTrusted: () => isPrimaryWorkspaceTrusted(), @@ -1877,6 +1891,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 8a16f99e093..5906cfaa643 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -13,20 +13,28 @@ 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'; function responseMock(): { response: Response; + set: ReturnType; status: ReturnType; json: ReturnType; } { + const set = vi.fn(); const status = vi.fn(); const json = vi.fn(); - const response = { status, json }; + const response = { set, status, json }; + set.mockReturnValue(response); status.mockReturnValue(response); json.mockReturnValue(response); - return { response: response as unknown as Response, status, json }; + return { response: response as unknown as Response, set, status, json }; } describe('sendBridgeError session writer errors', () => { @@ -122,4 +130,57 @@ describe('sendBridgeError session writer errors', () => { errorKind: 'session_writer_unavailable', }); }); + + 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', + }); + }); }); diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 3f99b5e4a6d..f0ac6cdb7c7 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -56,6 +56,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'; export type BridgeErrorContext = { @@ -70,6 +74,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.', @@ -235,6 +283,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 SessionWriterError) { res.status(err.httpStatus).json({ error: err.message, @@ -777,43 +843,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 fa73e9ca29c..71986791f87 100644 --- a/packages/cli/src/serve/server/serve-features.ts +++ b/packages/cli/src/serve/server/serve-features.ts @@ -57,6 +57,7 @@ interface CreateServeFeaturesDeps { realtimeVoiceEnabled: () => boolean; acpHttpEnabled?: boolean; workspaceRuntimeRemovalAvailable?: boolean; + workspaceRuntimeAvailable: () => boolean; workspaceTrustHotReloadAvailable?: boolean; isPrimaryWorkspaceTrusted?: () => boolean; env?: Readonly>; @@ -91,6 +92,7 @@ export function createServeFeatures( realtimeVoiceEnabled, acpHttpEnabled, workspaceRuntimeRemovalAvailable, + workspaceRuntimeAvailable, workspaceTrustHotReloadAvailable, } = deps; const getEnv = deps.getEnv ?? (() => deps.env ?? process.env); @@ -143,6 +145,7 @@ export function createServeFeatures( scratchWorkspaceRegistrationAvailable: scratchWorkspaceRegistrationAvailable(), workspaceRuntimeRemovalAvailable, + workspaceRuntimeAvailable: workspaceRuntimeAvailable(), workspaceTrustHotReloadAvailable, acpHttpEnabled: currentAcpHttpEnabled, realtimeVoiceEnabled: realtimeVoiceEnabled(), diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index e655a6b902d..6d1daba5fb0 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -311,7 +311,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 b4026c2765e..ba8dd378ddd 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'; export interface WorkspaceRuntimeEnvMetadata { readonly mode: 'parent-process' | 'runtime-overlay'; @@ -47,6 +48,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 db6637d7936..f4afaef62ac 100644 --- a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -2924,7 +2924,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 }), @@ -2933,7 +2933,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 () => { @@ -2983,13 +2983,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); @@ -2997,8 +2997,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(); @@ -3006,10 +3010,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 599043574d6..62c55094575 100644 --- a/packages/cli/src/serve/workspace-service/index.ts +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -265,7 +265,6 @@ export function createDaemonWorkspaceService( promise: Promise; } | undefined; - let inFlightAcpPreheat: Promise | undefined; const invalidateWorkspaceSkillsSnapshot = () => { workspaceSkillsGeneration += 1; @@ -409,9 +408,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. @@ -445,10 +444,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, @@ -457,41 +456,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/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 2d2b3cd4b28..da354d859d4 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -78,10 +78,13 @@ const rootDir = join(__dirname, '..'); // Bumped from 177KB to 178KB for workspace file byte-cursor paging after // merging the workspace pairing approval SDK surface. // Bumped from 178KB to 184KB for side-task session APIs and source metadata. -// Bumped from 184KB to 185KB for the Live Voice lifecycle helpers on both -// daemon client classes. -// Bumped from 185KB to 186KB for daemon-owned mid-turn message APIs. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 186 * 1024; +// Bumped from 184KB to 186KB when merging main: this branch and main each +// independently bumped to 185KB (primary/workspace-qualified runtime +// ensure/status helpers here; Live Voice lifecycle helpers on main, #7859), +// so the merged bundle sums both additions. +// Bumped from 186KB to 187KB for daemon-owned mid-turn message APIs merged +// from main after that bump (#8798). +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 187 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 1843b300653..5e66214013a 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -99,6 +99,7 @@ import type { DaemonWorkspaceProvidersStatus, DaemonWorkspaceAcpStatusResult, DaemonWorkspaceAcpPreheatResult, + DaemonWorkspaceRuntimeStatus, DaemonWorkspaceSkillsStatus, DaemonWorkspaceToolsStatus, DaemonWriteMemoryRequest, @@ -346,6 +347,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; @@ -1364,6 +1372,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`, @@ -4989,6 +5017,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 b474a85cdea..7f44efbdb4f 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -623,6 +623,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 f78d3d0ccb5..4447e621233 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -52,6 +52,7 @@ export interface DaemonWorkspaceRemovalActivity { memoryTasks: number; channelWorkers: number; voiceSessions?: number; + workspaceRuntime?: number; } export interface DaemonWorkspaceRemovalResult { @@ -1671,6 +1672,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 dca7a42b461..0ff6b664854 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -289,6 +289,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 8b2106273be..9d248b3d5e4 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -750,6 +750,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')) { @@ -761,6 +768,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); } @@ -772,12 +785,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'], ]); }); @@ -831,6 +852,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)); @@ -1170,6 +1253,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,