Skip to Content
EntwicklerhandbuchDaemonACP Bridge

ACP Bridge

Übersicht

packages/acp-bridge/ bildet die Grenze zwischen der HTTP-Schicht des Daemons und dem ACP-Kindprozess. Es wird von packages/cli/src/serve/ (dem qwen serve-Daemon) konsumiert und wurde in #4175 F1 Schritt 3 extrahiert, damit zukünftige Konsumenten (channels/base/AcpBridge.ts, der VS Code IDE Companion) dieselbe Bridge-Kernlogik nutzen können, ohne auf das CLI-Paket zugreifen zu müssen.

Jede aktive WorkspaceRuntime besitzt eine HttpAcpBridge-Instanz. Die Produktion versucht, die primäre Bridge vorzuwärmen und wiederholt den ersten Versuch nach einem Fehlschlag. Ein vertrauenswürdiger Secondary öffnet seinen AcpChannel und startet seinen Kindprozess on demand; ein nicht vertrauenswürdiger Secondary kann ACP nicht starten. Innerhalb der Runtime stellt die Bridge gemultiplexte Sessions über den Channel, sessionbezogene EventBus-Instanzen, einen MultiClientPermissionMediator, einen BridgeFileSystem-Adapter sowie ACP-orientierte Helper (spawnOrAttach, loadSession, resumeSession, sendPrompt, cancelSession, respondToPermission sowie extMethod-RPCs für Workspace-Status und MCP-Restart) bereit. Bridges und Kindprozesse werden niemals über Workspace-Runtimes hinweg geteilt.

Verantwortlichkeiten

  • Starten oder Anhängen an den ACP-Kindprozess über eine austauschbare ChannelFactory. Standard-Factory: defaultSpawnChannelFactory (Subprozess qwen --acp). Tests injizieren inMemoryChannel.
  • Verwalten von aliveChannels (Channel-Registry) und byId (Session-Registry).
  • Multiplexen von N HTTP-seitigen Sessions auf einen ACP-Kindprozess via connection.newSession().
  • Serialisieren von sessionbezogenen Prompts über promptQueue (ACP erzwingt einen aktiven Prompt pro Session).
  • Sessionbezogener FIFO für setSessionModel-Aufrufe, damit gleichzeitige Attaches mit unterschiedlichen Modellen nicht beim Agenten zu Race Conditions führen.
  • Sessionbezogener EventBus, der GET /session/:id/events antreibt (siehe 10-event-bus.md).
  • Permission-Flow: BridgeClient.requestPermissionMultiClientPermissionMediator.request → Fan-out → Vote-Sammlung → ACP-Antwort (siehe 04-permission-mediation.md).
  • Datei-I/O: BridgeFileSystem-Adapter für ACP-Lese- und Schreibvorgänge; Same-Host-Daemon-Runtimes bewerben readTextFile: false, sodass normale Text-Lesevorgänge im Child bleiben, während finale Text-Schreibvorgänge delegiert bleiben (siehe 07-workspace-filesystem.md).
  • extMethod-RPCs für Workspace-weiten Status (/workspace/mcp, /workspace/skills, /workspace/providers), MCP-Restart und den optionalen privaten Managed-Tool-Guard-Callback.
  • Lifecycle: Graceful shutdown() mit KILL_HARD_DEADLINE_MS (10s) pro Channel; synchrones killAllSync() für erzwungenes Beenden beim zweiten Signal.

Architektur

Öffentlicher Einstiegspunkt: createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge in packages/acp-bridge/src/bridge.ts.

Wichtige Typen:

TypeFileRole
HttpAcpBridgebridgeTypes.tsÖffentliche Schnittstelle: spawnOrAttach, loadSession, resumeSession, sendPrompt, cancelSession, subscribeEvents, respondToPermission, getWorkspaceMcpStatus, restartMcpServer, shutdown, killAllSync, …
BridgeSessionbridgeTypes.ts{ sessionId, workspaceCwd, attached, clientId?, createdAt? } wird an HTTP-Handler zurückgegeben.
BridgeOptionsbridgeOptions.tsKonfiguration zur Erstellungszeit (siehe Configuration).
AcpChannelchannel.ts{ stream, kill(), killSync(), exited } – ein ACP-NDJSON-Channel.
ChannelFactorychannel.ts(workspaceCwd, childEnvOverrides?) => Promise<AcpChannel>.
BridgeClientbridgeClient.tsKapselt eine ACP ClientSideConnection; implementiert ACP Client (requestPermission, readTextFile, writeTextFile, sessionUpdate, extNotification).
EventBuseventBus.tsSessionbezogener In-Memory-Pub/Sub. Siehe 10-event-bus.md.
MultiClientPermissionMediatorpermissionMediator.tsMediator mit vier Richtlinien. Siehe 04-permission-mediation.md.

Interner Zustand (über Closure in createHttpAcpBridge gebunden):

StateShapePurpose
aliveChannelsMap<string, ChannelInfo>Channel-Registry, gekeyed nach Channel-ID. Jede ChannelInfo enthält channel, connection, client (ein BridgeClient pro Channel), sessionIds: Set<string>, pendingRestoreIds, statusClosedReject?, isDying: boolean.
byIdMap<string, SessionEntry>Session-Registry, gekeyed nach sessionId. Jeder SessionEntry enthält 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>.
defaultEntrySessionEntry | nullDie “einzelne” Session, die bei sessionScope: 'single' verwendet wird.
defaultPolicyPermissionPolicyKonfiguriert über BridgeOptions.permissionPolicy.
mediatorMultiClientPermissionMediatorEine Instanz pro Bridge.
ConstantsDEFAULT_INIT_TIMEOUT_MS = 10_000, MCP_RESTART_TIMEOUT_MS = 300_000, DEFAULT_MAX_SESSIONS = 32, MAX_EVENT_RING_SIZE = 1_000_000, DEFAULT_PERMISSION_TIMEOUT_MS = 0, DEFAULT_MAX_PENDING_PER_SESSION = 64.

isDying-Invariante: Jeder Teardown-Pfad muss ChannelInfo.isDying = true synchron vor dem Awaiting von channel.kill() setzen. ensureChannel behandelt einen sterbenden Channel als nicht vorhanden und startet einen neuen. Ohne dieses Flag würde ein gleichzeitiger spawnOrAttach, der während des SIGTERM-Grace-Windows (bis zu 10s) eintrifft, an einen Transport anhängen, der gleich geschlossen wird, und die sessionId des Aufrufers würde bei jedem Folgeaufruf ein 404 zurückgeben. Set-Stellen (müssen synchron gehalten werden): ensureChannel (Initialisierungsfehler + Late-Shutdown-Re-Check), doSpawn (newSession-Fehler bei leerem Channel), killSession (letzte Session wird verlassen), shutdown (Bulk).

channelInfo-Retention-Invariante: channelInfo nicht löschen, wenn isDying = true gesetzt wird. killAllSync muss den Channel während des SIGTERM-Grace-Windows weiterhin finden können, um bei process.exit(1) SIGKILL auszulösen. aliveChannels hält den sterbenden Eintrag, bis channel.exited feuert.

BridgeClient Bounded Buffering: ACP extNotification-Frames, die auf BridgeClient für eine sessionId eintreffen, die noch nicht in byId ist (weil die Antwort von connection.newSession noch nicht zurückgekehrt ist, aber die MCP-Discovery innerhalb von newSession bereits Budget-Events gefeuert hat), werden in eine Early-Events-Queue gepuffert, begrenzt durch MAX_EARLY_EVENT_SESSIONS = 64 × MAX_EARLY_EVENTS_PER_SESSION = 32 × EARLY_EVENT_TTL_MS = 60_000. Der Worst Case sind etwa 400 KB Heap. Ohne Pufferung würde der erste SSE-Replay-Ring-Slot für eine neue Session Events vermissen, die während ihrer Erstellung gefeuert wurden.

Workflow

spawnOrAttach (primärer Einstiegspunkt)

Wichtige Punkte:

  • sessionScope='single' mit einem vorhandenen defaultEntry erhöht nur attachCount, registriert clientId und gibt attached: true zurück.
  • Der Cold Path führt die ChannelFactory aus, führt ACP initialize aus (DEFAULT_INIT_TIMEOUT_MS=10s), ruft connection.newSession({cwd}) auf und registriert dann den neuen SessionEntry.
  • SessionLimitExceededError wird geworfen, wenn byId.size >= maxSessions.
  • InvalidClientIdError wird geworfen, wenn X-Qwen-Client-Id außerhalb von [A-Za-z0-9._:-]{1,128} liegt.
  • Der Disconnect-Reaper in server.ts trackt den Spawn-Owner über attachCount/spawnOwnerWantedKill, um zu vermeiden, dass eine Session abgerissen wird, deren Spawn-Owner die Verbindung getrennt hat, aber andere Clients bereits attached sind (siehe #3889 BQ9tV).

Prompt-Serialisierung

Fehler am Ende der Queue werden unterdrückt, damit die Ablehnung eines vorherigen Prompts nachfolgende Prompts nicht vergiftet; der ursprüngliche Aufrufer erhält die Ablehnung weiterhin auf seinem eigenen zurückgegebenen Promise. Das auf der Session zwischengespeicherte transportClosedReject setzt das Prompt-Promise gegen channel.exited in ein Race, sodass ein abgestürzter Kindprozess sofort sichtbar wird, anstatt zu hängen.

Permission-Flow (High-Level)

InvalidPermissionOptionError wird vor dem Mediator geworfen, wenn ein Wire-Vote versucht, CANCEL_VOTE_SENTINEL über das normale optionId-Feld zu injizieren – der Sentinel ist die einzige Escape-Hatch der Bridge, um eine Anfrage als cancelled / agent_cancelled kurzzuschließen, und darf nicht versehentlich vom Wire erreichbar sein. Siehe 04-permission-mediation.md.

Shutdown

Channel-Factory

AcpChannel (channel.ts) ist die Transportabstraktion der Bridge. Die Produktion verwendet defaultSpawnChannelFactory in spawnChannel.ts, die qwen --acp als Subprozess mit einem Stdio-Pipe-Paar ausführt. Tests injizieren inMemoryChannel, um den Agenten In-Process auszuführen. Die Bridge weiß nichts über den zugrunde liegenden Mechanismus – sie benötigt nur { stream, kill, killSync, exited }.

ChannelFactory akzeptiert childEnvOverrides, damit jeder Daemon-Handle seine eigenen MCP-Budget-Umgebungsvariablen (QWEN_SERVE_MCP_CLIENT_BUDGET, QWEN_SERVE_MCP_BUDGET_MODE) übergeben kann, ohne process.env zu mutieren (was zu Race Conditions führen würde, wenn zwei eingebettete Daemons im selben Node-Prozess laufen).

Zustand & Lifecycle

  • Die Bridge-Erstellung ist synchron. Ein Aufrufer kann den Channel vor der ersten Session vorwärmen; andernfalls startet der erste spawnOrAttach den ACP-Kindprozess kalt. Ein fehlgeschlagenes Vorwärmen ermöglicht beim ersten Versuch einen erneuten Anlauf.
  • defaultEntry lebt über die Lebensdauer der Bridge bei sessionScope: 'single'; der Channel wird abgeräumt, wenn sessionIds.size === 0 (nach killSession) UND isDying auf true springt.
  • MAX_EVENT_RING_SIZE = 1_000_000 ist eine weiche Obergrenze für BridgeOptions.eventRingSize, um Tippfehler von Operatoren vor ~500 MB pro-Session OOMs abzufangen.
  • DEFAULT_PERMISSION_TIMEOUT_MS = 0 lässt menschliche Permissions und Fragen standardmäßig unbegrenzt warten. permissionResponseTimeoutMs aktiviert eine Wallclock-Obergrenze, wenn Operatoren eine benötigen; Voter-Cancellation, Session-Cancellation und Shutdown bleiben auch ohne verfügbar.
  • DEFAULT_MAX_PENDING_PER_SESSION = 64 spiegelt DEFAULT_MAX_SUBSCRIBERS wider; überschüssige requestPermission-Aufrufe werden als cancelled mit einer Stderr-Warnung aufgelöst.

Abhängigkeiten

UpstreamDownstream
@agentclientprotocol/sdkClientSideConnection, PROTOCOL_VERSION, ACP-Typenpackages/cli/src/serve/ (der Daemon)
@qwen-code/qwen-code-coreApprovalMode, TrustGateError, getCurrentGeminiMdFilenamepackages/channels/base/ (geplant, F4)
node:crypto, node:fs, node:pathpackages/vscode-ide-companion/ (geplant, F4)

Konfiguration

BridgeOptions (bridgeOptions.ts):

KeyDefaultPurpose
boundWorkspace(erforderlich)Kanonischer Workspace-Pfad, den die Bridge erzwingt.
sessionScope'single''single' teilt eine Session über alle Clients; 'thread' erstellt eine separate Session für jeden Konversations-Thread.
channelFactorydefaultSpawnChannelFactoryAustauschbare ACP-Kindprozess-Factory.
initializeTimeoutMsDEFAULT_INIT_TIMEOUT_MS = 10_000Timeout für den ACP initialize-Handshake.
sessionRestoreTimeoutMs60_000Timeout für ACP loadSession / unstable_resumeSession; standardmäßig 60s, und ein explizit konfiguriertes Initialize-Timeout kann es erhöhen, aber niemals senken.
maxSessionsDEFAULT_MAX_SESSIONS = 32Obergrenze für byId.size. 0 / Infinity = unbegrenzt; NaN/negativ wirft einen Fehler.
eventRingSizeDEFAULT_RING_SIZE (aus eventBus.ts)Sessionbezogener Event-Ring; weich gedeckelt bei MAX_EVENT_RING_SIZE.
permissionResponseTimeoutMsDEFAULT_PERMISSION_TIMEOUT_MS = 0Wallclock-Timeout pro Anfrage für den Mediator; 0 deaktiviert es.
maxPendingPermissionsPerSessionDEFAULT_MAX_PENDING_PER_SESSION = 64Backpressure für High-Volume-Agenten.
childEnvOverrides{}Handle-spezifische Env-Ergänzungen / -Bereinigungen für den ACP-Kindprozess.
externalToolGuard(keine)Optionaler Handler für die private Child-to-Parent-Pre-Execution-Entscheidung. Die Bridge akzeptiert ihn nur vom owning Channel für den aktuell aktiven Prompt.
persistApprovalMode, persistDisabledToolsSettings-Write-Hooks für die Wave-4-Mutationsrouten.
contextFilenameaus context.fileName in settings.jsonÜberschreibt getCurrentGeminiMdFilename.
statusProvider(keine)Daemon-Host-Preflight-Zellen (DaemonStatusProvider).
delegateReadTextFileToClienttrueNur auf false setzen für Same-Host-Runtimes, damit jeder FileSystemService.readTextFile-Consumer im Child den regulären CLI-Dateisystemdienst verwendet.
fileSystem(keine)BridgeFileSystem-Adapter für ACP readTextFile / writeTextFile.
permissionPolicyaus policy.permissionStrategy in settings.jsonEiner von first-responder / designated / consensus / local-only.
permissionConsensusQuorumaus settings.jsonN für Consensus-Policy.
permissionAuditcreateNoOpPermissionAuditPublisher()Verbindung zu PermissionAuditRing für den Audit-Trail.
channelIdleTimeoutMs0Hält den ACP-Kindprozess für diese Anzahl an Millisekunden am Leben, nachdem die letzte Session geschlossen wird.

Timeouts bei Restores sind im aktuellen ACP-SDK nicht abbrechbar. Die Bridge hält daher einen Settlement-Fence und eine Capacity-Admission aufrecht, bis die eigentliche Anfrage abschließt oder ihr Transport geschlossen wird. Ein verspätetes Ergebnis wird genau einmal abgeschlossen und niemals registriert. Unsicherheit bei der Bereinigung quarantänt nur neue Session-Arbeit auf diesem Workspace; bestehender Session- und Workspace-Control-Datenverkehr läuft weiter, bis der Channel gedrainet und recycelt wird.

Zusätzliche Bridge-Methoden

Neben den Kernaufrufen spawnOrAttach, sendPrompt, cancelSession, respondToPermission, loadSession und resumeSession umfasst die HttpAcpBridge-Schnittstelle nun diese Daemon-Hilfsfunktionen:

MethodeZweck
generateSessionRecap(sessionId, context?)Generiert eine einzeilige Zusammenfassung der Session.
generateSessionBtw(sessionId, question, signal?, context?)Beantwortet eine Nebenfrage / einen “btw”-Prompt.
executeShellCommand(sessionId, command, signal?, context?)Führt einen Shell-Befehl auf dem Daemon-Host aus.
getSessionContextUsageStatus(sessionId, opts?)Gibt die Context-Window-Nutzung zurück.
getSessionSupportedCommandsStatus(sessionId)Gibt die verfügbaren Slash-Befehle zurück.
getSessionTasksStatus(sessionId)Gibt einen Snapshot der Hintergrundtasks zurück.
getSessionStatsStatus(sessionId)Gibt die Nutzungsstatistiken der Session zurück.
setSessionApprovalMode(sessionId, mode, opts, context?)Aktualisiert den Approval-Modus für eine Session.
detachClient(sessionId, clientId?)Trennt einen Client explizit.
addRuntimeMcpServer(name, config, originatorClientId)Fügt zur Laufzeit einen MCP-Server hinzu.
removeRuntimeMcpServer(name, originatorClientId)Entfernt zur Laufzeit einen MCP-Server.
manageMcpServer(serverName, action, originatorClientId)Aktiviert / deaktiviert / authentifiziert / löscht die Authentifizierung.
generateWorkspaceAgent(description, originatorClientId)Generiert mit KI eine Subagent-Definition.
preheat()Wärmt das ACP-Child vor der ersten Session auf.
getSessionLastEventId(sessionId)Liest die monotone Event-ID der Session.
getWorkspaceToolsStatus()Gibt den Snapshot der integrierten Tool-Registry zurück.
getWorkspaceMcpToolsStatus(serverName)Gibt die Tools für einen bestimmten MCP-Server zurück.

BridgeSpawnRequest.sessionScope wurde von 'per-client' in 'thread' umbenannt. BridgeRestoredSession enthält nun compactedReplay, liveJournal und lastEventId. Diese Replay-Felder sind ein begrenztes In-Memory-Fenster für Live-Sessions, gedeckelt durch BridgeOptions.compactedReplayMaxBytes (Standard 4 MiB, harte Obergrenze 256 MiB). Das laufende liveJournal ist separat durch BridgeOptions.maxJournalEvents (Standard 10 000 Replay-Einträge) und BridgeOptions.maxJournalBytes (Standard 8 MiB serialisierter Source-Events) begrenzt. Aufeinanderfolgende kompatible Text- oder Thought-Chunks teilen sich einen Replay-Eintrag mit höchstens 256 Source-Events pro Eintrag; andere Event- und Attributionsgrenzen bleiben erhalten. Wenn älteres aufbewahrtes Replay verworfen wurde, ist compactedReplay[0] der id-lose history_truncated-Marker; wenn Journal-Einträge verworfen wurden, enthält liveJournal[0] einen history_truncated-Marker mit scope: 'live_journal'. Seine Retained- und Truncated-Counts beschreiben Source-Events, nicht Replay-Einträge. Das vollständig persistierte Transkript verbleibt auf der Festplatte und wird von dieser Bridge-Antwort nicht offengelegt. BridgeClientRequestContext ist der Request- Context, der durch die Bridge-Aufrufe gereicht wird; er enthält clientId, fromLoopback: boolean und promptId.

Einschränkungen & bekannte Limits

  • MCP_RESTART_TIMEOUT_MS = 300_000 (5 min) – Das Bridge-Timeout für /workspace/mcp/:server/restart ist absichtlich groß gewählt, da McpClientManager.MAX_DISCOVERY_TIMEOUT_MS für Stdio-Server bis zu 5 Minuten betragen kann. Ein kürzeres Zeitlimit würde zu Fehlern durch Timeouts führen, während das ACP-Child im Hintergrund weiterhin versucht, die Verbindung wiederherzustellen.
  • BridgeOptions.eventRingSize > 1_000_000 löst bei der Konstruktion einen Fehler aus.
  • connection.unstable_resumeSession wird über die stabile session_resume-Daemon-Capability bereitgestellt; unstable_session_resume bleibt als veralteter Kompatibilitäts-Alias für ältere SDKs verfügbar. Clients sollten session_resume per Feature-Detection erkennen.
  • Das Bridge-Paket ist @qwen-code/acp-bridge. Der aktuelle Code importiert Event-Bus- und Status-Primitiven direkt aus den Paket-Subpfaden; serve/acp-session-bridge.ts bleibt als CLI-lokale Kompatibilitäts-Fassade für die umfassendere Bridge-Oberfläche erhalten.

Referenzen

  • packages/acp-bridge/src/bridge.ts (insb. createHttpAcpBridge ab Zeile 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 .
Last updated on