qwen-code/docs/developers/daemon/03-acp-bridge.md
Shaojin Wen 77bd04bd61
fix(acp-bridge): bound live journal replay chunks (#8801)
* fix(acp-bridge): bound live journal replay chunks

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(core): isolate shell retention sidecars

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(integration): cover aggregated live journal replay

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(core): isolate registry sidecars

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(acp-bridge): keep unmodeled chunk keys out of live journal merges

The merged live-journal entry is rebuilt by spread-merging the first and
last source events, which was only safe because producers happen to emit
exactly {sessionUpdate, content, _meta?} on mergeable chunks. Gate the
merge on that key set so unmodeled data/update fields keep entries
discrete instead of leaking into the aggregate. Also clarify the
live-journal truncation marker: its retained/truncated counts describe
source events, while the limits count replay entries.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(acp-bridge): align replay boundaries for discrete and meta-shaped chunks

Turn compaction folded discrete thought chunks (and non-todo-stop-guard
discrete messages) into one text slot with the last chunk's meta, while
the live journal keeps every discrete chunk separate — resyncing from
compactedReplay mis-attributed text across background tasks. Guard both
chunk paths with the same hasDiscreteMessageMeta predicate the live
journal already uses. Also align the merge gate with the shapes the
shared meta builder emits: tolerate update-level timestamp/
serverTimestamp and qwenTranscript.planToolCallId, and treat an
empty-string parentToolCallId as top-level the way the extractor does.
Document that byte-cap truncation drops whole entries, so the retained
tail can be much smaller than the cap, and tighten the integration
assertion that became vacuous once entries merge source chunks.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(acp-bridge): merge subagent chunks in live journal replay

SubAgentTracker stamps every streamed subagent fragment with
{ parentToolCallId, subagentType }, but the live-journal merge gate
only modeled parentToolCallId, so subagent chunks stayed discrete and
a high-fragment subagent stream could still trip history_truncated.
Model subagentType as a carried label (like the completed-turn path,
which merges by parentToolCallId alone) and cover the producer wire
shape in the merge tests.

* fix(acp-bridge): preserve TextContent metadata in live journal replay (#8801)

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-10 09:52:17 +00:00

28 KiB
Raw Permalink Blame History

ACP Bridge

Overview

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 EventBuses, 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

  • Spawn or attach to the ACP child via a pluggable ChannelFactory. Default factory: defaultSpawnChannelFactory (subprocess qwen --acp). Tests inject inMemoryChannel.
  • Maintain aliveChannels (channel registry) and byId (session registry).
  • Multiplex N HTTP-side sessions for one workspace runtime onto its ACP child via connection.newSession().
  • Serialize per-session prompts through promptQueue (ACP enforces one active prompt per session).
  • Per-session FIFO for setSessionModel calls so concurrent attaches with different models do not race the agent.
  • Per-session EventBus that drives GET /session/:id/events (see 10-event-bus.md).
  • Permission flow: BridgeClient.requestPermissionMultiClientPermissionMediator.request → fan-out → vote collection → ACP response (see 04-permission-mediation.md).
  • File I/O: BridgeFileSystem adapter for ACP reads and writes; same-host daemon runtimes advertise readTextFile: false so normal text reads stay in the child while final text writes remain delegated (see 07-workspace-filesystem.md).
  • extMethod RPCs for workspace-level status (/workspace/mcp, /workspace/skills, /workspace/providers), MCP restart, and the optional private managed Tool Guard callback.
  • Lifecycle: graceful shutdown() with KILL_HARD_DEADLINE_MS (10s) per channel; synchronous killAllSync() for second-signal force-exit.

Architecture

Public entry: createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge in packages/acp-bridge/src/bridge.ts.

Key types:

Type File Role
HttpAcpBridge bridgeTypes.ts Public interface: spawnOrAttach, loadSession, resumeSession, sendPrompt, cancelSession, subscribeEvents, respondToPermission, getWorkspaceMcpStatus, restartMcpServer, shutdown, killAllSync, …
BridgeSession bridgeTypes.ts { sessionId, workspaceCwd, attached, clientId?, createdAt? } returned to HTTP handlers.
BridgeOptions bridgeOptions.ts Construction-time config (see Configuration).
AcpChannel channel.ts { stream, kill(), killSync(), exited } — one ACP NDJSON channel.
ChannelFactory channel.ts (workspaceCwd, childEnvOverrides?) => Promise<AcpChannel>.
BridgeClient bridgeClient.ts Wraps one ACP ClientSideConnection; implements ACP Client (requestPermission, readTextFile, writeTextFile, sessionUpdate, extNotification).
EventBus eventBus.ts Per-session in-memory pub/sub. See 10-event-bus.md.
MultiClientPermissionMediator permissionMediator.ts Four-policy mediator. See 04-permission-mediation.md.

Internal state (closed over by createHttpAcpBridge):

State Shape Purpose
aliveChannels Map<string, ChannelInfo> Channel registry keyed by channel id. Each ChannelInfo holds channel, connection, client (one BridgeClient per channel), sessionIds: Set<string>, pendingRestoreIds, statusClosedReject?, isDying: boolean.
byId Map<string, SessionEntry> Session registry keyed by sessionId. Each SessionEntry holds channel, connection, events: EventBus, promptQueue: Promise<void>, modelChangeQueue: Promise<void>, pendingPermissionIds: Set<string>, clientIds: Map<string, count>, activePromptOriginatorClientId?, attachCount, spawnOwnerWantedKill, restoreState?, sessionLastSeenAt?, clientLastSeenAt: Map<string, ms>.
defaultEntry SessionEntry | null The "single" session used when sessionScope: 'single'.
defaultPolicy PermissionPolicy Configured via BridgeOptions.permissionPolicy.
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).

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.

BridgeClient bounded buffering: ACP extNotification frames arriving on BridgeClient for a sessionId not yet in byId (because connection.newSession's response has not returned, but MCP discovery inside newSession already fired budget events) are buffered into an early-events queue bounded by MAX_EARLY_EVENT_SESSIONS = 64 × MAX_EARLY_EVENTS_PER_SESSION = 32 × EARLY_EVENT_TTL_MS = 60_000. The worst case is roughly 400 KB of heap. Without buffering, the first SSE replay-ring slot for a new session would be missing events that fired during its creation.

Workflow

spawnOrAttach (primary entry point)

sequenceDiagram
    autonumber
    participant R as Route handler
    participant B as createHttpAcpBridge closure
    participant CF as ChannelFactory
    participant CH as AcpChannel
    participant ACP as ACP child
    participant M as Mediator

    R->>B: spawnOrAttach({cwd?, sessionScope?, clientId?})
    B->>B: validate cwd vs boundWorkspace<br/>(WorkspaceMismatchError)
    alt sessionScope=single and defaultEntry exists
        B->>B: bump attachCount<br/>register clientId
        B-->>R: {sessionId, attached: true, restoreState?}
    else cold path
        B->>CF: factory(workspaceCwd, childEnvOverrides)
        CF->>ACP: spawn qwen --acp + pipes
        CF-->>B: AcpChannel
        B->>ACP: ACP initialize (timeout=DEFAULT_INIT_TIMEOUT_MS)
        ACP-->>B: initialize response
        B->>ACP: connection.newSession({cwd})
        ACP-->>B: {sessionId}
        B->>B: build SessionEntry<br/>register in byId / defaultEntry
        B-->>R: {sessionId, attached: false}
    end

Key points:

  • sessionScope='single' with an existing defaultEntry only bumps attachCount, registers clientId, and returns attached: true.
  • The cold path runs the ChannelFactory, performs ACP initialize (DEFAULT_INIT_TIMEOUT_MS=10s), calls connection.newSession({cwd}), then registers the new SessionEntry.
  • SessionLimitExceededError is thrown when byId.size >= maxSessions.
  • InvalidClientIdError is thrown if X-Qwen-Client-Id is outside [A-Za-z0-9._:-]{1,128}.
  • The disconnect-reaper in server.ts tracks the spawn owner via attachCount/spawnOwnerWantedKill to avoid tearing down a session whose spawn owner disconnected but other clients already attached (review #3889 BQ9tV).

Prompt serialization

sequenceDiagram
    autonumber
    participant R as Route
    participant E as SessionEntry
    participant Q as promptQueue (FIFO)
    participant BC as BridgeClient
    participant ACP as ACP child

    R->>E: sendPrompt(sessionId, body, clientId)
    E->>E: set activePromptOriginatorClientId = clientId
    E->>Q: chain off resolved tail
    Q->>BC: client.sendPrompt(sessionId, body)
    BC->>ACP: ACP prompt JSON-RPC
    ACP-->>BC: response (after potentially multiple requestPermission roundtrips)
    BC-->>E: result
    E->>E: clear activePromptOriginatorClientId
    E-->>R: result

Failures at the queue tail are swallowed so that a prior prompt's rejection does not poison subsequent prompts; the original caller still receives the rejection on its own returned promise. The transportClosedReject cached on the session races the prompt promise against channel.exited so a crashed child surfaces immediately rather than hanging.

Permission flow (high-level)

sequenceDiagram
    autonumber
    participant ACP as ACP child (agent)
    participant BC as BridgeClient.requestPermission
    participant E as SessionEntry
    participant M as Mediator
    participant EB as EventBus

    ACP->>BC: requestPermission(requestId, options)
    BC->>E: record requestId in pendingPermissionIds
    BC->>M: request({requestId, sessionId, originatorClientId, allowedOptionIds}, timeoutMs)
    M->>EB: publish permission_request (fan-out to subscribers)
    Note over M: waits for vote / timeout / cancel
    M-->>BC: PermissionResolution
    BC-->>ACP: RequestPermissionResponse (selected or cancelled)
    BC->>E: clear requestId

InvalidPermissionOptionError is thrown pre-mediator when a wire vote tries to inject CANCEL_VOTE_SENTINEL via the normal optionId field — the sentinel is the bridge's only escape hatch to short-circuit a request as cancelled / agent_cancelled and must not be reachable from the wire by accident. See 04-permission-mediation.md.

Shutdown

sequenceDiagram
    autonumber
    participant Op as runQwenServe
    participant B as Bridge
    participant CHs as Channels
    participant M as Mediator

    Op->>B: shutdown()
    B->>CHs: mark every ChannelInfo isDying = true (bulk)
    B->>M: forgetSession for every sessionId (pending → cancelled/session_closed)
    par per channel
        B->>CHs: channel.kill() (await up to KILL_HARD_DEADLINE_MS = 10s)
        CHs-->>B: exited
    end
    B-->>Op: done
    Note over Op,B: Second signal → killAllSync()<br/>(fire SIGKILL on every alive child synchronously)

Channel factory

AcpChannel (channel.ts) is the bridge's transport abstraction. Production uses defaultSpawnChannelFactory in spawnChannel.ts, which runs qwen --acp as a subprocess with a stdio pipe pair. Tests inject inMemoryChannel to run the agent in-process. The bridge knows nothing about the underlying mechanism — it only needs { stream, kill, killSync, exited }.

ChannelFactory accepts childEnvOverrides so each daemon handle can pass its own MCP-budget env vars (QWEN_SERVE_MCP_CLIENT_BUDGET, QWEN_SERVE_MCP_BUDGET_MODE) without mutating process.env (which would race when two embedded daemons run in the same Node process).

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

Dependencies

Upstream Downstream
@agentclientprotocol/sdkClientSideConnection, PROTOCOL_VERSION, ACP types packages/cli/src/serve/ (the daemon)
@qwen-code/qwen-code-coreApprovalMode, TrustGateError, getCurrentGeminiMdFilename packages/channels/base/ (planned, F4)
node:crypto, node:fs, node:path packages/vscode-ide-companion/ (planned, F4)

Configuration

BridgeOptions (bridgeOptions.ts):

Key Default Purpose
boundWorkspace (required) Canonical workspace path the bridge enforces.
sessionScope 'single' 'single' shares one session across all clients; 'thread' creates a separate session for each conversation thread.
channelFactory defaultSpawnChannelFactory Pluggable ACP child factory.
initializeTimeoutMs DEFAULT_INIT_TIMEOUT_MS = 10_000 ACP initialize handshake timeout.
sessionRestoreTimeoutMs 60_000 ACP loadSession / unstable_resumeSession timeout; defaults to 60s, and an explicitly configured initialize timeout can raise it but never lower it.
maxSessions DEFAULT_MAX_SESSIONS = 32 Cap on byId.size. 0 / Infinity = unlimited; NaN/negative throws.
eventRingSize DEFAULT_RING_SIZE (from eventBus.ts) Per-session event ring; soft-capped at MAX_EVENT_RING_SIZE.
permissionResponseTimeoutMs DEFAULT_PERMISSION_TIMEOUT_MS = 5 min Per-request wallclock for the mediator.
maxPendingPermissionsPerSession DEFAULT_MAX_PENDING_PER_SESSION = 64 Backpressure on high-volume agents.
childEnvOverrides {} Per-handle env additions / scrubs for the ACP child.
externalToolGuard (none) Optional handler for the private child-to-parent pre-execution decision. The bridge accepts it only from the owning channel for the currently active Prompt.
persistApprovalMode, persistDisabledTools Settings-write hooks for the Wave 4 mutation routes.
contextFilename from settings.json's context.fileName Overrides getCurrentGeminiMdFilename.
statusProvider (none) Daemon-host preflight cells (DaemonStatusProvider).
delegateReadTextFileToClient true Set false only for same-host runtimes so every child FileSystemService.readTextFile consumer uses the regular CLI filesystem service.
fileSystem (none) BridgeFileSystem adapter for ACP readTextFile / writeTextFile.
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.

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.

Additional bridge methods

In addition to the core spawnOrAttach, sendPrompt, cancelSession, respondToPermission, loadSession, and resumeSession calls, the HttpAcpBridge interface now includes these daemon-facing helpers:

Method Purpose
generateSessionRecap(sessionId, context?) Generate a one-line session recap.
generateSessionBtw(sessionId, question, signal?, context?) Answer a side question / btw prompt.
executeShellCommand(sessionId, command, signal?, context?) Run a shell command on the daemon host.
getSessionContextUsageStatus(sessionId, opts?) Return context-window usage.
getSessionSupportedCommandsStatus(sessionId) Return available slash commands.
getSessionTasksStatus(sessionId) Return a background-task snapshot.
getSessionStatsStatus(sessionId) Return session usage statistics.
setSessionApprovalMode(sessionId, mode, opts, context?) Update approval mode for a session.
detachClient(sessionId, clientId?) Explicitly detach a client.
addRuntimeMcpServer(name, config, originatorClientId) Add an MCP server at runtime.
removeRuntimeMcpServer(name, originatorClientId) Remove an MCP server at runtime.
manageMcpServer(serverName, action, originatorClientId) Enable / disable / authenticate / clear auth.
generateWorkspaceAgent(description, originatorClientId) Generate a subagent definition with AI.
preheat() Warm the ACP child before the first session.
getSessionLastEventId(sessionId) Read the session's monotonic event id.
getWorkspaceToolsStatus() Return the built-in tool registry snapshot.
getWorkspaceMcpToolsStatus(serverName) Return tools for a specific MCP server.

BridgeSpawnRequest.sessionScope was renamed from 'per-client' to 'thread'. BridgeRestoredSession now carries compactedReplay, liveJournal, and lastEventId. Those replay fields are a bounded in-memory window for live sessions, capped by BridgeOptions.compactedReplayMaxBytes (default 4 MiB, hard ceiling 256 MiB). The in-flight liveJournal is separately capped by BridgeOptions.maxJournalEvents (default 10 000 replay entries) and BridgeOptions.maxJournalBytes (default 8 MiB of serialized source events). Consecutive compatible text or thought chunks share a replay entry, with at most 256 source events per entry; other event and attribution boundaries remain intact. If older retained replay was dropped, compactedReplay[0] is the id-less history_truncated marker; if journal entries were dropped, liveJournal[0] carries a history_truncated marker with scope: 'live_journal'. Its retained and truncated counts describe source events, not replay entries. The full persisted transcript remains on disk and is not exposed by this bridge response. BridgeClientRequestContext is the request context threaded through bridge calls; it carries clientId, fromLoopback: boolean, and promptId.

Caveats & Known Limits

  • MCP_RESTART_TIMEOUT_MS = 300_000 (5 min) — the bridge timeout for /workspace/mcp/:server/restart is intentionally large because McpClientManager.MAX_DISCOVERY_TIMEOUT_MS can be up to 5 min for stdio servers. A shorter deadline would produce false timeouts while the ACP child kept reconnecting in the background.
  • BridgeOptions.eventRingSize > 1_000_000 throws at construction.
  • connection.unstable_resumeSession is exposed through the stable session_resume daemon capability; unstable_session_resume remains advertised as a deprecated compatibility alias for older SDKs. Clients should feature-detect session_resume.
  • The bridge package is @qwen-code/acp-bridge. Current code imports event-bus and status primitives directly from package subpaths; serve/acp-session-bridge.ts remains as the CLI-local compatibility facade for the broader bridge surface.

References

  • packages/acp-bridge/src/bridge.ts (esp. createHttpAcpBridge at line 350+)
  • packages/acp-bridge/src/bridgeClient.ts
  • packages/acp-bridge/src/bridgeTypes.ts
  • packages/acp-bridge/src/bridgeOptions.ts
  • packages/acp-bridge/src/channel.ts
  • packages/acp-bridge/src/spawnChannel.ts
  • packages/acp-bridge/src/bridgeErrors.ts
  • Issues: #3803, #4175.