Skip to content

proposal(serve): L2 能力分层 — 抽出 DaemonWorkspaceService,收口 file/auth/agents/memory(/acp↔REST 等价的前置) #4542

@chiga0

Description

@chiga0

关联:PR #4472(ACP Streamable HTTP transport /acp)、#3803(daemon open decisions)、#4175(Mode B roadmap)。
这是一条架构提案(design-first),先评审分层与 L2 收口方案,通过后再起实现 PR。

背景与问题

/acp(PR #4472)已对齐所有 bridge-backed 能力;要做成 REST+SSE 的完全等价替代,还差 4 类能力——文件 I/O、设备流登录、agents CRUD、memory CRUD。它们当前不在 HttpAcpBridge,REST 路由直连 route 级服务(WorkspaceFileSystemFactory / DeviceFlowRegistry / SubagentManager / writeWorkspaceContextFile),绕过了共享层。

同时审计发现 HttpAcpBridge名不副实:它横跨"应用能力层(L2)"与"对子进程的 ACP-client 出站腿(L3)",还兼任一批 daemon 本地工作区操作——已偏 God-object。

提案(推荐:完整 C)

抽出与 bridge 平级DaemonWorkspaceService(L2 兄弟,不经 bridge 委托),把 HttpAcpBridge 瘦身/改名为 AcpSessionBridge拆分原则 = 是否依赖 ACP child:依赖 live child → 留会话桥;纯 daemon 本地 → 进 WorkspaceService。两组件互不依赖,trust/TOCTOU/audit/每请求 context 封装为单点,REST 与 /acp 两个传输都调它。

不可动摇的原则:业务逻辑 + trust/audit 不能写在 L1 route handler 里;两个传输调用同一个传输无关的 L2。(这是通用软件架构问题,不是 ACP 要求——ACP 是 wire 协议,对内部分层无约束。)

方案对比(A 扩 bridge / B 直调服务 / C 新建门面)、决定性判据、client 接入样例、落点映射等完整内容见下方文档

待评审决策点

  1. L2 收口选 A / B / C?(提案推荐 C;理由见文档 §6)
  2. 是否同意 HttpAcpBridgeAcpSessionBridge 改名 + 把纯本地工作区操作迁入 DaemonWorkspaceService
  3. 迁移分期:前置 PR(建 service + REST 改走 service)→ 后续 /acp PR(加 _qwen/fs|auth|workspace/agent|memory)。

验收(完成后)

  • REST 与 /acp 对 file/auth/agents/memory 走同一 L2,trust/audit 单点;/acp 达到与 REST 完全等价
  • AcpSessionBridge 仅保留会话/child 相关职责;DaemonWorkspaceService 内聚 daemon 本地能力。

附:完整架构文档

acpBridge 的架构角色、分层模型与 client 接入规范

范围:qwen serve daemon 的分层架构,HttpAcpBridge 的真实职责,以及"其他 client 应该接入哪一层"。
基准:worktree qwen-code-acp-http(含 ACP-over-HTTP PR #4472)。日期:2026-05-26。
性质:架构评估快照;file:line 为撰写时状态,引用前请核对当前代码。
关联:同目录 daemon-architecture-overview.md(全景与多维评估);PR #4472;issue #3803 / #4175


0. TL;DR

  • HttpAcpBridge 已不只是"ACP 桥"——它横跨 应用/能力层(L2)对子进程的 ACP-client 出站腿(L3) 两层职责,事实上是 daemon 的服务门面,名字已名不副实。
  • L2 当前是裂开的:一半能力在 bridge(会话/事件/权限/部分工作区操作),一半散在 route 级服务(文件 I/O、设备流、agents、memory)。这是要收口的核心问题。
  • 接入规则(端口-适配器):所有 client 接 L1 传输适配器/acp 标准 或 REST 既有);L1 必须薄,业务/鉴权/审计下沉 L2任何 client 都不得直连 L3/L4(自起子进程、绕过 token/工作区信任边界)。
  • 已知漂移channels/AcpBridge.ts 的 standalone 模式自起子进程,直达 L3/L4,绕过 daemon —— 应统一走 L1→L2,或明确成独立部署形态。

1. HttpAcpBridge 的真实职责(代码佐证)

当前 HttpAcpBridgepackages/acp-bridge/src/bridge.ts + bridgeTypes.ts)的 ~31 个方法分两类:

1.1 真正的 ACP-child 操作(会调 connection.*,发往子进程)

  • spawnOrAttach / loadSession / resumeSession(→ connection.newSession / loadSession
  • sendPrompt(→ connection.prompt)、cancelSession(→ connection.cancel
  • 权限请求经 requestPermission 回流(agent→client)

1.2 daemon 本地操作(完全不碰 child,只读设置/文件/状态)

  • getWorkspaceMcpStatus / Skills / Providers / Env / Preflight
  • initWorkspacesetWorkspaceToolEnabledrestartMcpServer
  • listWorkspaceSessionsrecordHeartbeatgetHeartbeatState
  • updateSessionMetadatagetSessionContextStatusgetSessionSupportedCommandsStatus

1.3 还内含「对子进程的 ACP-client 适配器」

  • ChannelInfo.connection = new ClientSideConnection(..., channel.stream)channel.stream = ndJsonStream(child stdin/stdout)
  • 还有 EventBus(每会话 ring + Last-Event-ID 重放 + 背压/驱逐)、权限 mediator、单子进程多路复用 N 会话的生命周期。

结论:bridge = 应用门面(L2) + ACP-client 出站腿(L3) 二合一。它已经承担了一批"非 ACP"的 daemon 工作区操作,所以并不是纯粹的协议桥。

1.4 不在 bridge 上的能力(缺口)

file I/O(/file /glob /list /stat /file/write /file/edit)、设备流登录(/workspace/auth/*)、agents CRUD(/workspace/agents)、memory CRUD(/workspace/memory)—— 这些只在 REST 路由里,直连 route 级服务(WorkspaceFileSystemFactory / DeviceFlowRegistry / SubagentManager / writeWorkspaceContextFile),没有进 L2 门面


2. 分层架构图

                                  CLIENTS(actors)
  webui      TS/Java/Py SDK    钉钉/微信/TG channels     Zed/Goose(ACP原生)    future
    │              │                  │                       │                │
════╪══════════════╪══════════════════╪═══════════════════════╪════════════════╪═══ L1 传输适配器 (inbound adapters)
  REST+SSE      REST+SSE        DaemonChannelBridge          /acp             [WS*]
  server.ts     sdk/daemon      →(走 REST)                 acpHttp/         (deferred)
    └──────────────┴──────────────────┴───────────────────────┘
                         │   ★ 所有适配器都应汇入同一层,且自身保持"薄" ★
════════════════════════╪══════════════════════════════════════════════════ L2 应用/能力层(应当唯一)
        ┌───────────────┴────────────────────────────┐    ┌─────────────────────────────┐
        │  HttpAcpBridge(façade,现状)               │    │ route 级服务(现状裂在 L2 之外)│
        │  • 会话/通道生命周期(1 child 多路复用)        │    │  WorkspaceFileSystemFactory   │
        │  • EventBus(每会话 ring/重放/背压)            │    │  DeviceFlowRegistry           │
        │  • 权限仲裁 mediator                          │    │  SubagentManager              │
        │  • daemon 本地工作区操作(status/init/tool/    │    │  writeWorkspaceContextFile    │
        │    mcp-restart)                              │    │  ← file/auth/agents/memory    │
        └───────────────┬──────────────────────────────┘    │     缺口:未并入 façade        │
                        │                                    └─────────────────────────────┘
════════════════════════╪══════════════════════════════════════════════════ L3 ACP 出站腿(→ agent)
                        │  ClientSideConnection / ndJsonStream (stdio JSON-RPC)
                        ▼
════════════════════════════════════════════════════════════════════════════ L4 Agent(子进程)
        qwen --acp : sessions Map → GeminiChat(模型回合) + 每会话 MCP pool → core engine

┄┄ 漂移(drift) ┄┄ channels/AcpBridge.ts 自己 spawn child ─────────────────────► 直达 L3/L4,绕过 L1+L2 daemon

层定义:

  • L1 传输适配器:把某种 wire 协议编解码成内部调用。REST+SSE(server.ts)、/acpacpHttp/,JSON-RPC over HTTP + 双 SSE)、未来 WebSocket。必须薄——只做协议编解码 + 路由 + 鉴权拦截,不放业务。
  • L2 应用/能力层:传输无关的能力面 + 单一的 trust/TOCTOU/audit 执行点。目标是唯一;现状裂成 bridge façade + 旁挂服务。
  • L3 ACP 出站腿:daemon 作为 ACP client 对子进程 agent 的适配器(stdio ndJsonStream)。当前与 L2 融合在 bridge 里。
  • L4 Agentqwen --acp 子进程,跑 core 引擎 + 每会话 MCP pool;ACP agent 角色。

注意方向:northbound(L1,client→daemon)daemon 是 ACP agent;southbound(L3,daemon→child)daemon 是 ACP client。bridge 同时是 northbound 能力门面和 southbound client 适配器。


3. 其他 client 应该接入哪一层?(接入规范)

规则(端口-适配器 / hexagonal):

接哪层 说明
所有外部 client(编辑器 / SDK / web / channel) L1(选一个传输适配器) ACP 原生客户端 → /acp(标准);既有 web/SDK → REST+SSE。不要为新 client 再造第三套 bespoke 传输
L1 适配器自身 保持:协议编解码 + 路由 + 鉴权拦截 业务 / 鉴权决策 / 审计一律下沉 L2
新增能力(file/auth/agents/memory…) 加在 L2,由所有 L1 适配器共享暴露 绝不写在某个 L1 handler 里(否则 REST 与 /acp 必然漂移、trust/audit 两套实现)
任何 client 不得直连 L3/L4 不能自己起 child、不能绕过 daemon 的 bearer token + 单工作区信任边界

3.1 两个直接结论

  1. channels standalone(channels/AcpBridge.ts 自起 child)= 架构漂移:一个 client 直达 L3/L4,绕过 L1+L2 的鉴权/单工作区/审计。应统一走 DaemonChannelBridge → L1(REST 或 /acp) → L2;若保留 standalone,应明确成"另一种部署形态",不作默认。
  2. 未来 client 一律接 L1 的 /acp(标准 ACP,零 qwen 胶水),而非新开路由——这正是引入 /acp 的核心价值。

4. L2 收口(统一能力层)—— 待决方案 A/B/C

把 file/auth/agents/memory 从"L1 路由直连服务"收进统一 L2,有三个选项(细节见 #3803 / #4175 评论与 daemon-acp-http 设计文档 §17.3):

方案 做法 取舍
A 扩到 HttpAcpBridge 与已在 bridge 上的同类工作区操作一致;/acp 只依赖 bridge。代价:继续撑大已偏 façade 的 bridge
B 保留独立服务,REST 与 /acp 都直接调 最轻、bridge 不膨胀;代价:/acp 需注入这些服务
C 新建 DaemonWorkspaceService 门面,两传输共用 内聚/命名最干净;代价:多一层

不可动摇的原则(与具体选项无关):业务逻辑 + trust/TOCTOU/audit 不能写在 L1 route handler 里,REST 与 /acp 两个传输都要调用同一个传输无关的 L2

这是通用软件架构问题(端口-适配器 / 单一事实源 / DRY)不是 ACP 的要求——ACP 是 wire 协议,对 daemon 内部分层无任何约束。

倾向 A(一致性最好、/acp 只依赖 bridge),前提是承认 HttpAcpBridge 现实上已是 daemon 服务门面(可考虑改名);若要保住其 ACP 纯粹性则选 B/C。最终待 owner 拍板。


5. 可选的进一步演进(非本轮必须)

  • 拆 L2 与 L3:把 bridge 内的 ClientSideConnection(对 child 的 ACP-client 适配器)从能力门面里分出,让 L2 纯做能力、L3 纯做"对 agent 的传输"。便于 Stage 2 in-process(去子进程)时只换 L3。
  • L1 收敛:长期把 REST 做成 /acp 的薄 compat shim(设计文档既定方向),最终 L1 只剩 /acp(+WS) 一套标准面 + 一层 REST 兼容垫片。
  • 重命名 HttpAcpBridge → 更贴合其 daemon-façade 现实的名字(若采纳 A)。

6. 方案评估与推荐(L2 收口:A vs B vs C)

把 file/auth/agents/memory 从"L1 路由直连服务"收进统一 L2,三个选项:

  • A:扩到 HttpAcpBridge
  • B:保留独立服务,REST 与 /acp 都直接调。
  • C:新建 DaemonWorkspaceService 门面(L2 兄弟),两传输共用。

6.1 决定性判据:trust/audit/context 的封装点

这 4 类能力真正的风险不是功能,而是 trust gate / TOCTOU / audit / 每请求上下文(clientId、loopback、audit originator) 的执行(REST 现在用 fsFactory.forRequest({ originatorClientId, route }) 这样穿线)。核心问题:这套上下文穿线封装在一处(两传输都穿不错),还是各穿一遍(会漂移)?

判据 A 扩 bridge B 两传输直调服务 C 新建 WorkspaceService
trust/context 封装为单点 ❌ 两处穿线→漂移
内聚 / 单一职责 ❌ 撑大 God-object ⚠️ "一袋松散服务"不算层
与现状一致性 ✅ bridge 已有同类操作 ❌ 加剧分裂 ⚠️ 最干净(需迁现有操作才彻底)
/acp 耦合 ✅ 只依赖 bridge ➖ 多 4 依赖 ➖ 多 1 依赖
改动量/风险 低(但复制穿线)
利于 Stage 2 / L2-L3 拆分 / 改名 ❌ 全焊死 bridge ➖ 中性 ✅ 天然分离

排序:架构质量 C > A > B;在唯一要紧的轴(trust 单点封装)上 A、C 合格,B 不合格

6.2 推荐:完整 C(按"更清晰、分层更合理、耦合更少"取舍)

抽出与 bridge 平级的 DaemonWorkspaceService不经 bridge 委托——委托反而加耦合),HttpAcpBridge 瘦身/改名为 AcpSessionBridge

            L1            REST+SSE        /acp        [WS*]
                            │ │            │ │
              ┌─────────────┘ └─────┐  ┌───┘ └────┐
              ▼                     ▼  ▼          ▼
══ L2(两个聚焦兄弟,互不依赖)═══════════════════════════════
   ┌──────────────────────────┐   ┌──────────────────────────────┐
   │ AcpSessionBridge          │   │ DaemonWorkspaceService(新)  │
   │ • 会话/通道生命周期        │   │ • file I/O                    │
   │ • prompt/cancel/EventBus  │   │ • device-flow auth            │
   │ • 权限仲裁                 │   │ • agents CRUD                 │
   │ • 依赖 child 的内省/状态    │   │ • memory CRUD                 │
   │   (mcp/skills/preflight…) │   │ • workspace init/tool/env     │
   └───────────┬──────────────┘   │ • 统一 RequestContext          │
               │ L3 → child        └──────────────────────────────┘

拆分原则 = 是否依赖 ACP child: 依赖 live child(prompt/会话/mcp·skills·preflight 状态/context)→ 留 AcpSessionBridge;纯 daemon 本地(文件/登录/agents/memory/init/tool/env)→ 进 DaemonWorkspaceService。两组件互不依赖;跨切需求(如 tool-toggle 走 EventBus)用单向注入(service 持 event-publish 回调,不反向)。

为什么满足三条:更清晰(各一职责、命名诚实);分层合理(L1 薄 → L2 聚焦 → L3 连接,trust/context 单点封装);耦合更少(bridge 不背工作区杂活、service 不依赖 bridge、两传输按域调聚焦接口)。

代价(已知):比 A 改动大——新建 service + 统一 context;REST 现有 file/auth/agents/memory 路由改为调 service;把 bridge 上纯本地工作区操作迁入 service;/acp 新增 _qwen/fs|auth|workspace/agent|memory 方法。鉴于优先架构质量,此代价值得。

重申:这是通用软件架构问题(端口-适配器/单一事实源/DRY)不是 ACP 要求——ACP 是 wire 协议,对内部分层无约束。


7. Client 接入样例

核心:L2 拆分对 client 完全透明。 client 只连一个 L1 传输面、一套 token;daemon 内部把"会话域"路由给 AcpSessionBridge、"工作区域"路由给 DaemonWorkspaceService

7.1 ACP 原生 client 走 /acp(一条连接,跨两个域)

BASE=http://127.0.0.1:8767/acp ; TOK="Authorization: Bearer devtoken"

# 1) 握手 → 响应头 Acp-Connection-Id: C1 + capabilities(_meta.qwen.methods)
curl -sD- -XPOST $BASE -H "$TOK" -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
# 2) 连接级 SSE(承载 session/new 回复 + 工作区调用回复)
curl -sN $BASE -H "$TOK" -H 'acp-connection-id: C1' &

# ── 会话域(→ AcpSessionBridge)──
# 3) 建会话(202;回复落 conn SSE: {id:2,result:{sessionId:S1}})
curl -XPOST $BASE -H "$TOK" -H 'acp-connection-id: C1' -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"session/new","params":{}}'
# 4) 会话级 SSE(session/update、request_permission)
curl -sN $BASE -H "$TOK" -H 'acp-connection-id: C1' -H 'acp-session-id: S1' &
# 5) prompt(202;update 流 + 最终 result 落 session SSE)
curl -XPOST $BASE ... -d '{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{"sessionId":"S1","prompt":[{"type":"text","text":"hi"}]}}'
# 6) 切模型(标准方法)
curl -XPOST $BASE ... -d '{"jsonrpc":"2.0","id":4,"method":"session/set_config_option","params":{"sessionId":"S1","configId":"model","value":"qwen-max"}}'

# ── 工作区域(→ DaemonWorkspaceService)—— 同一连接,无需 session ──
curl -XPOST $BASE ... -d '{"jsonrpc":"2.0","id":5,"method":"_qwen/workspace/agents"}'        # [Follow-up]
curl -XPOST $BASE ... -d '{"jsonrpc":"2.0","id":6,"method":"_qwen/fs/read","params":{"path":"src/app.ts"}}'  # [Follow-up]
curl -XPOST $BASE ... -d '{"jsonrpc":"2.0","id":7,"method":"_qwen/workspace/mcp"}'            # 已实现

第 5–7 步无 sessionId、与会话调用共用同一连接与 conn SSE——client 感知不到它们落到不同 L2 组件。

7.2 Web/SDK client 走 REST+SSE(等价)

会话域:  POST /session → {sessionId:S1};  GET /session/S1/events (SSE);
         POST /session/S1/prompt;  POST /session/S1/model
工作区域: GET /workspace/agents;  GET /file?path=…;  GET /workspace/mcp

7.3 落点映射(client 调用 → L1 → L2)

client 调用 L1 L2 组件
session/new·prompt·cancel·set_config_option、SSE session/update /acp 或 REST AcpSessionBridge
_qwen/workspace/agents·_qwen/fs/read·_qwen/workspace/memory·_qwen/auth/* /acp 或 REST DaemonWorkspaceService
_qwen/workspace/{mcp,skills,providers,env,preflight}session/context /acp 或 REST 依赖 child→Bridge;纯本地→Service

7.4 SDK

DaemonClient/DaemonSessionClientsdk-typescript/src/daemon/)把 REST+SSE 封装成方法;未来给 /acp 出对等 helper(工作区域用 SDK extMethod('_qwen/…'))。无论哪种,client 看到统一 API,L2 拆分始终隐藏在 L1 之后。

[Follow-up]_qwen/fs/*·_qwen/workspace/agents·_qwen/auth/*·_qwen/workspace/memory 为 C 方案落地后才有;其余现已实现。

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions