|
| 1 | +# Daemon 架构 |
| 2 | + |
| 3 | +## 概览 |
| 4 | + |
| 5 | +一个 `qwen serve` 进程坚持 **一 daemon = 一 workspace** 的不变式。它内嵌一个 Express HTTP 服务、持有一个 `acp-bridge` 实例、拉起一个 ACP 子进程(`qwen --acp`)来跑真正的 agent 运行时。多个客户端(CLI TUI、IDE companion、IM channel 机器人、Web BFF、自定义脚本)通过 HTTP + SSE 连进来,要么共享同一个 ACP session(`sessionScope: 'single'`,默认),要么每个客户端各拿一个(`'thread'`)。 |
| 6 | + |
| 7 | +在 ACP 子进程内部,MCP server 通过 `McpTransportPool`(F2)实现工作区内共享:一对 (server name + 配置指纹) 对应一条 MCP transport,不管被几个 session 发现都只起一份。Bridge 的 `MultiClientPermissionMediator`(F3)在四种策略之一下协调多客户端的权限投票。 |
| 8 | + |
| 9 | +本文给出 **系统级全景**,本文档集其余 18 篇文档都挂在它下面。每条主干流程都给一张 Mermaid 时序图,单个组件的实现细节请看对应的专题文档。 |
| 10 | + |
| 11 | +## 进程拓扑 |
| 12 | + |
| 13 | +```mermaid |
| 14 | +flowchart LR |
| 15 | + subgraph clients["Clients"] |
| 16 | + WUI["Web UI<br/>(packages/webui/src/daemon)"] |
| 17 | + TUI["CLI TUI<br/>(待迁移到 SDK ui/*)"] |
| 18 | + IDE["VSCode IDE<br/>(packages/vscode-ide-companion)"] |
| 19 | + CH["Channel bots<br/>(DingTalk / WeChat / Telegram)"] |
| 20 | + SDK["Any SDK consumer<br/>(packages/sdk-typescript/src/daemon)"] |
| 21 | + end |
| 22 | +
|
| 23 | + subgraph daemon["qwen serve process (one workspace)"] |
| 24 | + EXP["Express app<br/>(packages/cli/src/serve/server.ts)"] |
| 25 | + BR["AcpBridge<br/>(packages/acp-bridge/src/bridge.ts)"] |
| 26 | + MED["MultiClientPermissionMediator<br/>(F3)"] |
| 27 | + EB["EventBus per session<br/>(eventBus.ts)"] |
| 28 | + FS["WorkspaceFileSystem<br/>(cli/src/serve/fs/)"] |
| 29 | + end |
| 30 | +
|
| 31 | + subgraph child["ACP child process (qwen --acp)"] |
| 32 | + AGT["QwenAgent runtime"] |
| 33 | + POOL["McpTransportPool<br/>(F2, core/src/tools)"] |
| 34 | + BDG["WorkspaceMcpBudget"] |
| 35 | + end |
| 36 | +
|
| 37 | + subgraph external["External"] |
| 38 | + MCP1["MCP server A<br/>(stdio)"] |
| 39 | + MCP2["MCP server B<br/>(websocket)"] |
| 40 | + end |
| 41 | +
|
| 42 | + WUI -- "HTTP+SSE" --> EXP |
| 43 | + TUI -- "HTTP+SSE" --> EXP |
| 44 | + IDE -- "HTTP+SSE (loopback)" --> EXP |
| 45 | + CH -- "HTTP+SSE" --> EXP |
| 46 | + SDK -- "HTTP+SSE" --> EXP |
| 47 | +
|
| 48 | + EXP --> BR |
| 49 | + BR --> MED |
| 50 | + BR --> EB |
| 51 | + EXP --> FS |
| 52 | +
|
| 53 | + BR -- "ACP NDJSON over stdio" --> AGT |
| 54 | + AGT --> POOL |
| 55 | + POOL --> BDG |
| 56 | + POOL -- "shared transport" --> MCP1 |
| 57 | + POOL -- "shared transport" --> MCP2 |
| 58 | +``` |
| 59 | + |
| 60 | +要点: |
| 61 | + |
| 62 | +- daemon 进程与 ACP 子进程通过 `AcpChannel` 连接,默认是真实的子进程 + 一对管道;`inMemoryChannel` 用于测试。 |
| 63 | +- 所有架构都被这条「daemon ↔ child」缝隙塑造:HTTP / SSE 在 daemon 终止,agent 决策与工具调用在子进程发生,bridge 是中转。 |
| 64 | + |
| 65 | +## 包关系 |
| 66 | + |
| 67 | +```mermaid |
| 68 | +flowchart TB |
| 69 | + subgraph serve["packages/cli/src/serve"] |
| 70 | + RQS["runQwenServe.ts<br/>(bootstrap)"] |
| 71 | + SRV["server.ts (Express)"] |
| 72 | + CAP["capabilities.ts"] |
| 73 | + AUTH["auth.ts"] |
| 74 | + FSM["fs/ (sandbox)"] |
| 75 | + DSP["daemonStatusProvider.ts"] |
| 76 | + end |
| 77 | +
|
| 78 | + subgraph br["packages/acp-bridge"] |
| 79 | + BR2["bridge.ts"] |
| 80 | + BC2["bridgeClient.ts"] |
| 81 | + EB2["eventBus.ts"] |
| 82 | + MED2["permissionMediator.ts"] |
| 83 | + ST2["status.ts"] |
| 84 | + CH2["channel.ts / spawnChannel.ts"] |
| 85 | + end |
| 86 | +
|
| 87 | + subgraph core["packages/core/src/tools"] |
| 88 | + POOL2["mcp-transport-pool.ts"] |
| 89 | + ENT["mcp-pool-entry.ts"] |
| 90 | + WBG["mcp-workspace-budget.ts"] |
| 91 | + SMV["session-mcp-view.ts"] |
| 92 | + end |
| 93 | +
|
| 94 | + subgraph sdk["packages/sdk-typescript/src/daemon"] |
| 95 | + DC["DaemonClient.ts"] |
| 96 | + DSC["DaemonSessionClient.ts"] |
| 97 | + EVT["events.ts"] |
| 98 | + SSE["sse.ts"] |
| 99 | + AUTHF["DaemonAuthFlow.ts"] |
| 100 | + UI["ui/* (#4328 + #4353)<br/>normalizer / transcript / store / render"] |
| 101 | + end |
| 102 | +
|
| 103 | + subgraph adapters["Adapters"] |
| 104 | + WUIP["webui/src/daemon/<br/>DaemonSessionProvider.tsx"] |
| 105 | + CHB["channels/base/<br/>DaemonChannelBridge.ts"] |
| 106 | + DT["channels/dingtalk"] |
| 107 | + WX["channels/weixin"] |
| 108 | + TG["channels/telegram"] |
| 109 | + IDEA["vscode-ide-companion/<br/>daemonIdeConnection.ts"] |
| 110 | + end |
| 111 | +
|
| 112 | + RQS --> SRV |
| 113 | + RQS --> CAP |
| 114 | + RQS --> AUTH |
| 115 | + RQS --> FSM |
| 116 | + RQS --> BR2 |
| 117 | +
|
| 118 | + BR2 --> BC2 |
| 119 | + BR2 --> EB2 |
| 120 | + BR2 --> MED2 |
| 121 | + BR2 --> CH2 |
| 122 | +
|
| 123 | + BR2 -.spawns.-> core |
| 124 | + POOL2 --> ENT |
| 125 | + POOL2 --> WBG |
| 126 | + POOL2 --> SMV |
| 127 | +
|
| 128 | + WUIP --> DSC |
| 129 | + WUIP --> UI |
| 130 | + CHB --> DSC |
| 131 | + DT --> CHB |
| 132 | + WX --> CHB |
| 133 | + TG --> CHB |
| 134 | + IDEA --> DSC |
| 135 | +
|
| 136 | + DSC --> DC |
| 137 | + DC --> EVT |
| 138 | + DC --> SSE |
| 139 | + DC --> AUTHF |
| 140 | + UI --> EVT |
| 141 | +``` |
| 142 | + |
| 143 | +记住三条信任边界: |
| 144 | + |
| 145 | +1. HTTP 入口边界:`serve/auth.ts` 中间件链。 |
| 146 | +2. bridge ↔ ACP 子进程边界:stdio 上的 NDJSON,没有认证 —— 子进程默认信任 bridge。 |
| 147 | +3. agent ↔ MCP server 边界:agent 可能触发涉及宿主资源的工具调用。 |
| 148 | + |
| 149 | +## 流程 1:HTTP 请求生命周期 |
| 150 | + |
| 151 | +```mermaid |
| 152 | +sequenceDiagram |
| 153 | + autonumber |
| 154 | + participant C as Client (SDK) |
| 155 | + participant MW as Middleware<br/>(CORS→host→bearer→mutationGate) |
| 156 | + participant R as Route handler |
| 157 | + participant BR as AcpBridge |
| 158 | + participant BC as BridgeClient |
| 159 | + participant CH as ACP child |
| 160 | +
|
| 161 | + C->>MW: POST /session/:id/prompt<br/>Authorization: Bearer …<br/>X-Qwen-Client-Id: … |
| 162 | + MW->>MW: denyBrowserOriginCors |
| 163 | + MW->>MW: hostAllowlist (DNS rebinding guard) |
| 164 | + MW->>MW: bearerAuth (constant-time compare) |
| 165 | + MW->>MW: mutationGate (strict on mutating routes) |
| 166 | + MW->>R: req validated |
| 167 | + R->>BR: bridge.sendPrompt(sessionId, body, clientId) |
| 168 | + BR->>BC: client.sendPrompt(sessionId, …) |
| 169 | + BC->>CH: ACP JSON-RPC over stdin |
| 170 | + CH-->>BC: ACP response / notifications |
| 171 | + BC-->>BR: result |
| 172 | + BR-->>R: result |
| 173 | + R-->>C: 200 JSON |
| 174 | +``` |
| 175 | + |
| 176 | +非流式路由(prompt、cancel、model 切换、metadata、workspace CRUD)以一次 JSON 响应结束。流式输出**不是**在该 HTTP 连接上以分块方式返回,而是走 SSE 通道;见流程 2。 |
| 177 | + |
| 178 | +## 流程 2:SSE 事件投递与重放 |
| 179 | + |
| 180 | +```mermaid |
| 181 | +sequenceDiagram |
| 182 | + autonumber |
| 183 | + participant C as Client |
| 184 | + participant SR as GET /session/:id/events |
| 185 | + participant EB as EventBus<br/>(per session) |
| 186 | + participant BC as BridgeClient |
| 187 | + participant CH as ACP child |
| 188 | +
|
| 189 | + C->>SR: GET …/events<br/>Last-Event-ID: 42 (optional) |
| 190 | + SR->>EB: subscribe(lastSeenId=42, maxQueued=N) |
| 191 | + EB-->>SR: replay frames 43..currentTail<br/>(from ring buffer) |
| 192 | + SR-->>C: NDJSON: id=43, type=session_update, … |
| 193 | + CH-->>BC: ACP notification (e.g. agent_message_chunk) |
| 194 | + BC->>EB: publish({type, data}) |
| 195 | + EB-->>SR: enqueue id=N |
| 196 | + SR-->>C: id=N, type=…, data=… |
| 197 | + Note over EB,SR: If subscriber queue >= maxQueued,<br/>EventBus emits client_evicted terminal frame<br/>and closes subscriber. |
| 198 | +``` |
| 199 | + |
| 200 | +要点: |
| 201 | + |
| 202 | +- 环形缓冲有上限(`eventRingSize`,默认 8000)。 |
| 203 | +- 重连客户端如果 `Last-Event-ID` 已经落出环外,会收到合成 catch-up 信号,必须用 `loadSession` / `resumeSession` 重建更深层状态。 |
| 204 | +- 慢消费者在队列 75% 触发 `slow_client_warning`,达到上限时收到 `client_evicted`(终态)后被关掉。 |
| 205 | + |
| 206 | +## 流程 3:多客户端权限协调 |
| 207 | + |
| 208 | +```mermaid |
| 209 | +sequenceDiagram |
| 210 | + autonumber |
| 211 | + participant CH as ACP child (agent) |
| 212 | + participant BC as BridgeClient.requestPermission |
| 213 | + participant MED as Mediator (policy) |
| 214 | + participant EB as EventBus |
| 215 | + participant C1 as Client A<br/>(originator) |
| 216 | + participant C2 as Client B |
| 217 | +
|
| 218 | + CH->>BC: ACP requestPermission(requestId, options) |
| 219 | + BC->>MED: request({requestId, sessionId, originatorClientId, allowedOptionIds}, timeoutMs) |
| 220 | + MED->>EB: publish permission_request<br/>(broadcast to subscribers) |
| 221 | + EB-->>C1: SSE permission_request |
| 222 | + EB-->>C2: SSE permission_request |
| 223 | +
|
| 224 | + alt first-responder |
| 225 | + C2->>MED: POST /permission/:requestId optionId=allow |
| 226 | + MED-->>BC: resolved |
| 227 | + BC-->>CH: ACP response |
| 228 | + MED->>EB: permission_resolved |
| 229 | + C1->>MED: POST /permission/:requestId (late vote) |
| 230 | + MED-->>C1: 409 permission_already_resolved |
| 231 | + else designated |
| 232 | + C2->>MED: vote (clientId != originatorClientId) |
| 233 | + MED-->>C2: 403 permission_forbidden |
| 234 | + C1->>MED: vote (matches originator) |
| 235 | + MED-->>BC: resolved |
| 236 | + else consensus (N-of-M) |
| 237 | + C1->>MED: vote |
| 238 | + MED->>EB: permission_partial_vote (1/N) |
| 239 | + C2->>MED: vote |
| 240 | + MED->>EB: permission_partial_vote (2/N) |
| 241 | + Note over MED: when tally reaches quorum on one option, resolve |
| 242 | + else local-only |
| 243 | + C2->>MED: vote (remote) |
| 244 | + MED-->>C2: 403 permission_forbidden (remote_not_allowed) |
| 245 | + Note over MED,CH: blocks until a loopback voter resolves it |
| 246 | + end |
| 247 | +``` |
| 248 | + |
| 249 | +跨策略「逃生口」:任何客户端都可以投 `CANCEL_VOTE_SENTINEL` 把请求短路成 `cancelled / agent_cancelled`。bridge 防止 wire 端通过普通 `optionId` 字段偷偷塞这个哨兵(`InvalidPermissionOptionError`)。 |
| 250 | + |
| 251 | +四种策略一句话: |
| 252 | + |
| 253 | +- `first-responder` — 第一个有效投票获胜(默认,保留 live 协作 UX)。 |
| 254 | +- `designated` — 只有 `originatorClientId` 能投,其他客户端收 `permission_forbidden`。 |
| 255 | +- `consensus` — N-of-M 法定人数,过程中发 `permission_partial_vote` 让 UI 渲染进度。 |
| 256 | +- `local-only` — 拒绝任何 HTTP 投票,只接受 loopback。 |
| 257 | + |
| 258 | +## 流程 4:MCP transport 池的 acquire / release / restart |
| 259 | + |
| 260 | +```mermaid |
| 261 | +sequenceDiagram |
| 262 | + autonumber |
| 263 | + participant S as Session in ACP child |
| 264 | + participant P as McpTransportPool |
| 265 | + participant SIF as spawnInFlight (dedup) |
| 266 | + participant E as PoolEntry |
| 267 | + participant BDG as WorkspaceMcpBudget |
| 268 | + participant SRV as MCP server |
| 269 | +
|
| 270 | + S->>P: acquire(name, cfg, sessionId) |
| 271 | + P->>SIF: check inflight for (name+fingerprint) |
| 272 | + alt cached inflight |
| 273 | + SIF-->>P: existing promise |
| 274 | + else cold start |
| 275 | + P->>BDG: tryReserve(name) |
| 276 | + BDG-->>P: ok / refused |
| 277 | + alt refused |
| 278 | + P-->>S: BudgetExhaustedError |
| 279 | + else ok |
| 280 | + P->>E: new PoolEntry(...) |
| 281 | + E->>SRV: connect transport |
| 282 | + SRV-->>E: ready |
| 283 | + E-->>P: connected |
| 284 | + end |
| 285 | + end |
| 286 | + P->>P: sessionToEntries.add(sessionId, id) |
| 287 | + P-->>S: PooledConnection |
| 288 | +
|
| 289 | + Note over S,P: Session uses entry, then… |
| 290 | +
|
| 291 | + S->>P: release(id, sessionId) |
| 292 | + P->>E: detach session |
| 293 | + E->>E: arm drain timer (default 30s) |
| 294 | + Note over E: refs==0 → drain timer fires → close transport<br/>(MAX_IDLE_MS 5min hard cap survives flap) |
| 295 | +
|
| 296 | + Note over S,P: Operator restart flow… |
| 297 | + S->>P: restartByName(name, opts?) |
| 298 | + P->>E: drain + close |
| 299 | + P->>E: spawn replacement |
| 300 | + E->>SRV: reconnect |
| 301 | + P->>EB: publish mcp_server_restarted |
| 302 | +``` |
| 303 | + |
| 304 | +要点: |
| 305 | + |
| 306 | +- `releaseSession(sessionId)` 借助 `sessionToEntries` 反向索引,以 O(refs) 释放该 session 持有的所有条目。 |
| 307 | +- daemon 关停时 `drainAll()` 置 `draining` 标志(拒绝新的 acquire),并以可配置超时等待所有条目关闭。 |
| 308 | +- `restartByName` 可以接 `entryIndex` 来精确重启某条;池里同名多条目时返回 `{entries: RestartResult[]}` 形状。 |
| 309 | + |
| 310 | +## 流程 5:生命周期 —— 启动与优雅退出 |
| 311 | + |
| 312 | +```mermaid |
| 313 | +sequenceDiagram |
| 314 | + autonumber |
| 315 | + participant Op as Operator (signal) |
| 316 | + participant RQS as runQwenServe |
| 317 | + participant APP as Express app |
| 318 | + participant BR as AcpBridge |
| 319 | + participant CH as ACP child |
| 320 | +
|
| 321 | + Op->>RQS: qwen serve --workspace … --token … |
| 322 | + RQS->>RQS: validate flags + canonicalize workspace |
| 323 | + RQS->>RQS: allocate PermissionAuditRing |
| 324 | + RQS->>BR: createHttpAcpBridge(options) |
| 325 | + RQS->>APP: createServeApp(bridge, …) |
| 326 | + RQS->>APP: listen(host, port) |
| 327 | + RQS->>RQS: arm SIGINT / SIGTERM handlers |
| 328 | +
|
| 329 | + Op->>RQS: SIGTERM |
| 330 | + RQS->>BR: dispose device-flow registry |
| 331 | + RQS->>BR: bridge.shutdown() |
| 332 | + BR->>CH: send graceful close (10s deadline) |
| 333 | + CH-->>BR: exit |
| 334 | + RQS->>APP: server.close() (5s force-close timer) |
| 335 | + APP->>APP: closeAllConnections() (+2s secondary) |
| 336 | + Note over Op,RQS: Second SIGTERM during shutdown →<br/>bridge.killAllSync() + process.exit(1) (orphan prevention) |
| 337 | +``` |
| 338 | + |
| 339 | +为什么要分两阶段: |
| 340 | + |
| 341 | +- 还在飞的 HTTP 请求、还连着的 SSE 订阅者、子进程里还在跑的工具调用都需要有上限的退出窗口。 |
| 342 | +- 任何一条卡过窗口,force-close 路径会接管,避免子进程把 daemon 进程拖住。 |
| 343 | +- 第二次 SIGTERM 直接走 `bridge.killAllSync()` + `process.exit(1)`,防孤儿。 |
| 344 | + |
| 345 | +## 关键文件 |
| 346 | + |
| 347 | +| 关注点 | 文件 | |
| 348 | +| ------------------ | -------------------------------------------------------------------- | |
| 349 | +| Bootstrap | `packages/cli/src/serve/runQwenServe.ts` | |
| 350 | +| Express 应用 | `packages/cli/src/serve/server.ts` | |
| 351 | +| 能力注册表 | `packages/cli/src/serve/capabilities.ts` | |
| 352 | +| Auth 中间件 | `packages/cli/src/serve/auth.ts` | |
| 353 | +| Bridge | `packages/acp-bridge/src/bridge.ts` | |
| 354 | +| BridgeClient | `packages/acp-bridge/src/bridgeClient.ts` | |
| 355 | +| 权限协调器 | `packages/acp-bridge/src/permissionMediator.ts` | |
| 356 | +| EventBus | `packages/acp-bridge/src/eventBus.ts` | |
| 357 | +| MCP transport 池 | `packages/core/src/tools/mcp-transport-pool.ts` | |
| 358 | +| Workspace MCP 预算 | `packages/core/src/tools/mcp-workspace-budget.ts` | |
| 359 | +| Workspace 文件系统 | `packages/cli/src/serve/fs/` | |
| 360 | +| SDK DaemonClient | `packages/sdk-typescript/src/daemon/DaemonClient.ts` | |
| 361 | +| SDK SessionClient | `packages/sdk-typescript/src/daemon/DaemonSessionClient.ts` (61-385) | |
| 362 | +| 事件 schema | `packages/sdk-typescript/src/daemon/events.ts` (14-112) | |
| 363 | + |
| 364 | +## 参考 |
| 365 | + |
| 366 | +- 设计 issue:[#3803](https://github.com/QwenLM/qwen-code/issues/3803)(daemon 总体设计)、[#4175](https://github.com/QwenLM/qwen-code/issues/4175)(F 系列里程碑)。 |
| 367 | +- 用户使用文档:[`../../users/qwen-serve.md`](../../users/qwen-serve.md)。 |
| 368 | +- Wire 协议参考:[`../qwen-serve-protocol.md`](../qwen-serve-protocol.md)。 |
| 369 | +- F2 设计文档(v2.2,含 32 条 review fold-in):[`../../design/f2-mcp-transport-pool.md`](../../design/f2-mcp-transport-pool.md)。 |
| 370 | +- F2 设计笔记:issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) commit 4-6。 |
0 commit comments