ACP-over-HTTP — Wiederaufnehmbarer Session-Event-Stream (Last-Event-ID)
Status: Design + Implementierung in diesem PR. Schließt die Wiederaufnehmbarkeitslücke, die als RFD Phase 4 in
README.md§7 / Zeile “Resume cursor (ringLast-Event-ID)” erfasst ist.
Problem
Der /acp Streamable-HTTP Session-Event-Stream (GET /acp mit einem Acp-Session-Id Header) ist live-only: Er gibt weder eine SSE id:-Sequenz aus noch berücksichtigt er einen Last-Event-ID Request-Header beim erneuten Verbinden.
Wenn ein Control-Plane-Proxy die langlebige SSE-Verbindung mitten in einem Turn aufgrund von Inaktivität schließt (der Daemon selbst sendet retry: 3000, und Ingress-Proxys trennen lange SSE-Verbindungen häufig), verbindet sich der Client neu und übernimmt erneut die Ownership, aber jeder Content-Frame, den der Daemon während der Lücke erzeugt hat, geht verloren — session/update-Benachrichtigungen, die agent_thought_chunk / agent_message_chunk tragen. Der Turn erreicht dennoch einen Terminalzustand (ein turn_complete wird erzeugt/synthetisiert), sodass die UI “done” mit einem leeren oder abgeschnittenen Body anzeigt. Das erneute Senden desselben Prompts funktioniert, was der entscheidende Hinweis ist: Der Verlust liegt in der Transportlücke, nicht im Modell.
Symptome und Feldbelege sind in den Integrationsnotizen als §1.8 (sdk-known-issues.md) katalogisiert.
Was bereits existiert (und warum das hier klein ist)
Die Replay-Engine ist bereits gebaut und im Einsatz bewährt — die Lücke besteht nur darin, dass der /acp-Transport nicht damit verbunden ist.
packages/acp-bridge/src/eventBus.ts:
- Monotone, sitzungsspezifische
id, beginnend bei 1 (nextId, zugewiesen inpublish()). - Begrenzter Ringbuffer pro Session (
DEFAULT_RING_SIZE = 8000, Operator-Overrideqwen serve --event-ring-size). subscribeEvents(sessionId, { lastEventId, signal })spielt Ring-Frames mitid > lastEventIdab, bevor Live-Events fließen, und gibt die synthetischen Control-Framesreplay_complete,state_resync_required(aus dem Ring verdrängt / Epoch-Reset bei Daemon-Neustart),client_evicted,slow_client_warningaus.
Die REST-Surface GET /session/:id/events nutzt bereits all dies: Sie liest last-event-id (server.ts → parseLastEventId), übergibt sie an subscribeEvents und serialisiert jeden Frame mit einer SSE id:-Zeile (formatSseFrame). Der Bug ist, dass der /acp-Transport nichts davon tut:
| Layer | REST /session/:id/events | /acp GET (heute) |
|---|---|---|
liest Last-Event-ID Header | ja | nein |
übergibt lastEventId an subscribeEvents | ja | nein (dispatch.ts pumpSessionEvents) |
gibt SSE id:-Zeile aus | ja (formatSseFrame) | nein (SseStream.send schreibt nur data:) |
acp-http/sse-stream.ts sagt dies sogar in einem Kommentar: “no ring-buffer id: sequencing — resumability is RFD Phase 4, deferred.” Dieser PR entfernt diese Verschiebung.
Wire-Entscheidung — SSE id:-Zeile (nicht _meta im Payload)
Die beiden SSE-Surfaces tragen unterschiedliche Payloads:
- REST streamt
BridgeEvent-Envelopes ({ id, v, type, data, _meta }). Der SDK-Parser (sdk-typescript/src/daemon/sse.ts) extrahiert den Cursor aus demid-Feld der JSON-Envelope (er liest nurdata:-Zeilen). /acpstreamt rohe JSON-RPC 2.0-Objekte (session/update-Benachrichtigungen,session/request_permission-Requests, Responses). Diese haben keine Envelope-id, um einen Bus-Cursor zu tragen, und eine JSON-RPC-idbedeutet etwas anderes (Request-ID).
Daher ist der Resume-Cursor für /acp die Standard-SSE id:-Zeile:
- Sie ist EventSource-nativ — ein spezifikationskonformer SSE-Client (inkl. des vendorten
AcpHttpTransport) trackt automatisch die letzteid:und sendet sie beim erneuten Verbinden automatisch alsLast-Event-IDHeader zurück. - Sie hält den JSON-RPC-Payload sauber (keine Injektion von nicht standardkonformen
_meta.qwen.eventIdin Protokoll-Frames). - Sie spiegelt wider, was
formatSseFramebereits bei REST ausgibt, sodass beide Surfaces dieselbeneventBus-IDs und dieselbeLast-Event-ID-Semantik teilen.
Nur Bus-ursprüngliche Frames tragen eine id: (session/update, session/request_permission, vom Daemon gepushte Notifies). JSON-RPC-Responses/Replies, die den Session-Stream nutzen, sind keine Bus-Events und tragen keine id: — sie sind nicht im Ring und werden absichtlich nicht vom Replay getrackt (eine verlorene In-Flight-Prompt-Response ist das separat verfolgte §1.7-Problem, das hier nicht im Scope ist; §1.8 handelt von verlorenen Content-Frames, bei denen es sich ausschließlich um Bus-session/update-Events handelt).
Synthetische Terminal-Frames (client_evicted, stream_error, …) haben keine Bus-id und geben daher keine id:-Zeile aus — passend zu REST, damit sie keinen Slot in der monotonen Sequenz verbrauchen, von der aus der Client das Resume durchführt.
Änderungen
transport-stream.ts—send(message, id?: number). Die optionaleidist die Bus-Event-ID für das SSE-Cursor-Tracking.sse-stream.ts—send(message, id?)stelltid: ${id}\nvor diedata:-Zeile, wennid !== undefined(spiegeltformatSseFrame).ws-stream.ts—send(message, id?)akzeptiert und ignoriertid: WebSocket ist eine zustandsbehaftete Verbindung, kein SSE-Replay (konsistent mitAcpWsTransport.supportsReplay = false).connection-registry.ts—sendSession(sessionId, frame, id?)reichtidanstream.sendweiter. Der sitzungsspezifische Pre-Attach-Buffer speichert{ frame, id? }-Paare, sodass ein gepuffertes Frame seinen Cursor behält, wenn es beim Attach geflusht wird. (Der verbindungsweite Buffer bleibt unverändert – diese Frames sind JSON-RPC-Responses ohne Bus-ID.)dispatch.tstranslateEventübergibtevent.idbei jedemsendSession- /binding.stream.send-Aufruf für Bus-Events.pumpSessionEvents(conn, sessionId, signal, lastEventId?)leitetlastEventIdansubscribeEventsweiter – wobei das vorhandene Ring-Replay direkt wiederverwendet wird.
index.ts— derGET /acpSession-Stream-Branch liest denLast-Event-IDHeader (über ein striktesparseLastEventId, dieselbe Regel “nur Dezimalziffern akzeptieren” wie bei REST) und übergibt ihn anpumpSessionEvents.
Keine eventBus-/Bridge-Änderungen – die Engine wird unverändert wiederverwendet.
Damit Resume tatsächlich greift (Session-Stream Grace/Reclaim)
Die obige id:/Last-Event-ID-Verkabelung ist notwendig, aber nicht hinreichend — allein löst sie im echten Flow nichts aus. Zuvor führte der GET-Handler bei einem Transport-Level-Abbruch eines Session-SSE-Streams den vollständigen closeSessionStream-Teardown durch: Er entfernte die Session aus ownedSessions, brach den In-Flight-Prompt ab und trennte den Bridge-Client. In der echten EventSource/Proxy-Reihenfolge (alter Socket schließt zuerst, dann verbindet der Client neu) bedeutet das, dass ein Reconnect mit Last-Event-ID von der Ownership-Prüfung mit 403 abgelehnt wird, bevor der Cursor überhaupt gelesen wird – und der Prompt, der den Content erzeugt hat, wurde bereits abgebrochen. Die Replay-Engine hätte nichts, wozu sie sich neu verbinden könnte.
Daher führt ein Transport-Level-Session-Stream-Abbruch jetzt nur noch ein Detach durch (AcpConnection.detachSessionStream), anstatt einen Teardown auszulösen: Er stoppt nur den Stream + sein Event-Abonnement und hält das Binding, die Ownership, den In-Flight-Prompt und die Bridge-Client-Registrierung für ein Grace-Window (SESSION_GRACE_MS, analog zu CONN_GRACE_MS) am Leben. Ein Reconnect innerhalb des Fensters hängt sich wieder an (attachSessionStream löscht den Grace-Timer – Reclaim) und das Ring-Replay füllt die Lücke. Wenn kein Reconnect eintrifft, löst der Grace-Timer den vollständigen Teardown aus – was die Kosten für einen durchgehenden Prompt begrenzt. Der vollständige Teardown bleibt bei einem expliziten session/close und beim Verbindungs-Teardown (destroy) sofort. Der GET-Handler verzweigt anhand von stream.isClosed: Ein Transport-Abbruch → Detach-with-grace; eine Pump, die endet, während der Stream noch offen ist (Subprozess fertig / Iterator-Fehler) → vollständiges Schließen (Zombie-Stream).
Zwei Replay-Korrektheits-Guards, die dadurch freigeschaltet werden
Beide sind latent, bis Resume tatsächlich läuft; das obige Grace/Reclaim macht sie erreichbar, daher werden sie zusammen ausgeliefert:
- Keine Doppelzustellung UND kein stiller Verlust (Buffer ↔ Ring). Ein gepuffertes Bus-Event befindet sich auch im EventBus-Ring (es wurde dort veröffentlicht, um seine ID zu erhalten). Daher erhält
attachSessionStreambei einem Resume (Last-Event-IDvorhanden) den Cursor und flusht überhaupt keine ID-tragenden gepufferten Frames – das Ring-Replay (gestartet am Cursor des Clients) ist der einzige Zustellungspfad für jedes Bus-Event nach dem Cursor. Dies ist absichtlich nicht “Buffer flushen, dann den Replay-Cursor darüber hinaus schieben”: Ein Frame, der an den jetzt toten Socket gesendet, aber nie vom Client empfangen wurde, hat eine ID unterhalb der Buffer-IDs, aber oberhalb des Client-Cursors. Würde man den Cursor über den Buffer hinweg schieben, würde dieser stillschweigend verworfen werden. Wenn der Ring alle Bus-Events besitzt, wird jedes genau einmal ohne Lücke zugestellt. ID-lose Frames (überreplySessiongeroutete JSON-RPC-Replies) sind keine Ring-Events, der Ring wird sie also nicht erneut zustellen – aber sie dürfen auch beim Attach nicht geflusht werden: Ein gepuffertessession/prompt-Result, das vor dem Replay geflusht wird, würde vor den Content-Chunks ankommen, die ihm vorausgingen (der Client sieht “done” vor dem Body – genau der Truncated-Body-Fehler, den §1.8 behebt). Daher werden beim Resume die ID-losen Frames zurückgestellt: Sie verbleiben im Buffer, und die Event-Pump gibt sie frei (flushBufferedSessionFrames), sobald das Replay abgearbeitet ist – nur beireplay_complete, um die ursprüngliche Stream-Reihenfolge beizubehalten. Wichtig: NICHT beistate_resync_required: Der EventBus gibt diesen Frame vor den Replay-Frames aus (und gibt dann am Ende immer nochreplay_completeaus), ein Flush dabei würde die Reply vor den replayten Content setzen. Der Live-only-Fall (keineLast-Event-ID⇒ kein Replay ⇒ keinreplay_complete) wird durch den Post-Loop-Safety-Flush der Pump abgedeckt. (Ein Fresh-Connect ohneLast-Event-IDhat keinen Ring-Anker, daher flusht er den gesamten Buffer sofort und in der Reihenfolge wie zuvor.) - Idempotente
permission_requestunter Replay. Einepermission_requestist ein ID-tragendes Ring-Event, daher replayt ein Reconnect, dessen Cursor einer noch unbeantworteten Permission vorausgeht, diese.translateEventverwendet nun den vorhandenenconn.pending-Eintrag für diesebridgeRequestIderneut (sendet dieselbe ausgehende JSON-RPC-ID zum Aufholen erneut), anstatt eine zweite ID + einen zweiten Eintrag zu prägen – kein verwaistes Pending, kein Double-Prompt für einen Client, der auf_meta.requestIddedupliziert.
parseLastEventId wird in ein gemeinsames serve/sse-last-event-id.ts extrahiert, das von beiden REST- und /acp-Surfaces verwendet wird, sodass ihre strengen Accept/Reject-Regeln und das Operator-Logging nicht auseinanderdriften können.
Abwärtskompatibilität
- Alte Clients, die keine
Last-Event-IDsenden →lastEventIdistundefined→subscribeEventsstartet live, genau wie heute. - Das Hinzufügen von
id:-Zeilen ist abwärtskompatibles SSE – ein Client, der das Feld ignoriert, ist nicht betroffen; ein EventSource-basierter beginnt kostenlos, es zu tracken. - Der vendorte SDK-
AcpHttpTransportoptet in diesem PR in das Replay ein – er setztsupportsReplay = trueund sendetLast-Event-IDbeim Reconnect erneut, sodass Lücken-Frames aus dem Ring replayt werden und der §1.8-Content-Verlust geschlossen wird, ohne dass weitere Daemon-Änderungen erforderlich sind. (Der separate externeagent-web-Transport-Flip bleibt verschoben – siehe “Nicht im Scope”.) Die Daemon-Änderung bleibt für jeden Consumer inert, der weiterhinsupportsReplay = falsemeldet und den Header weglässt. - Die REST-Surface bleibt unberührt.
Testplan
sse-stream.test.ts—send(msg, 7)gibtid: 7\nvordata:aus;send(msg)(keine ID) lässt dieid:-Zeile weg; Reihenfolgeid:→data:→ Leerzeile.transport.test.ts(End-to-End über den/acp-Transport):- Live-
session/update-Frames kommen jetzt mit einerid:-Zeile an; - ein
GET /acpmitLast-Event-ID: Nleitet den Cursor ansubscribeEventsweiter; ein neuer Stream ohne Header verhält sich wie heute; - eine überlaufende
Last-Event-ID(>MAX_SAFE_INTEGER) → live-only; - echte Close-then-Reconnect-Reihenfolge: alten SSE zuerst schließen, dann mit
Last-Event-IDneu verbinden – 200 nicht 403 assertieren (Ownership behalten) und der Prompt wird nicht abgebrochen (Grace/Reclaim); - eine replayte
permission_requestverwendet den Pending-Eintrag erneut (dieselbe ausgehende ID).
- Live-
connection-registry.test.ts— ein Non-Resume-Attach flusht den gesamten Buffer und reicht dieidjedes Frames durch; ein Resume-Attach (Cursor vorhanden) überspringt die ID-tragenden Frames (Ring-Replay besitzt sie), flusht aber weiterhin ID-lose JSON-RPC-Replies;detachSessionStreambehält Ownership/Prompt über das Grace-Window bei und führt dann beim Ablauf den Teardown durch; ein Reconnect innerhalb des Fensters reclaimt (bricht den ausstehenden Teardown ab).ws-stream.test.ts—send(msg, id)ignoriert die ID: Der WS-Wire-Frame ist das nackte JSON, kein SSE-id:-Framing sickert durch.
Nicht im Scope (weiterhin verschoben)
- WebSocket- / HTTP/2-Transports.
- §1.7 Verbindungsübergreifendes Permission-Resolve (ein Vote, das auf einer anderen
Acp-Connection-Idgepostet wird als der, die den Prompt gestreamt hat) – ein separates, sicherheitskritisches Anliegen, das als eigenes Follow-up verfolgt wird. Dieser PR macht diepermission_request-Übersetzung zwar idempotent unter Replay (siehe oben), fügt aber nicht das sitzungsglobale RequestId-Resolve hinzu. Er fügt auch keine Response-Replay-Idempotenz für eine BEREITS AUFGEKLÄRTE Permission hinzu: Sobald der Client gevotet hat, wird der Pending-Eintrag verbraucht, sodass ein späterer Reconnect, der die (noch im Ring befindliche)permission_requestreplayt, den Prompt mit derselben_meta.requestIderneut sendet. Ein konformer Client dedupliziert anhand dieser ID (der Vertrag, auf den sich der Replay-Pfad bereits verlässt) und der verbleibende verwaiste Pending-Eintrag wird beim Teardown aufgeräumt – der Agent blockiert nie – aber das Aufzeichnen von aufgelösten Ergebnissen in einer begrenzten sitzungsspezifischen LRU, um das aufgezeichnete Vote erneut zu senden (volle Idempotenz für nicht-deduplizierende Clients), gehört in dasselbe Permission-Koordinations-Follow-up, da es den Zustand der aufgelösten Permission zum Vote-Pfad hinzufügt. - Die verlorene In-Flight-Prompt-Response auf dem Session-Stream – wiederhergestellte Content-Frames fließen alle durch den
eventBus-Ring; eine JSON-RPC-Response ist kein Ring-Event. - Consumer-seitiger
supportsReplay-Flip im externenagent-web-AcpHttpTransport(befindet sich in einem anderen Repo; wird durch diesen PR entblockt). - Permission-Voting über die exportierten SDK-Transports. Der exportierte
AcpHttpTransport/AcpWsTransportstelltsession/request_permissionalspermission_request-Event bereit, aber die Vote-APIs des SDK (respondToPermission/respondToSessionPermission) mappen auf einensession/permission-Request, für den der ACP-Daemon keinen Handler hat – er akzeptiert ein Permission-Vote nur als JSON-RPC-Response, die die ausgehende_qwen_perm_N-ID echo’t. Die Verkabelung des Vote-Roundtrips ist Teil des §1.7 Permission-Koordinations-Follow-ups. Ein verwandter Aspekt: Die No-Subscriber-Session-Reply-Pump (ensureSessionReplyPump) öffnet einen echtenGET /acpSession-Stream, den der Daemon als Live-Stream behandelt – daher wird ein Agent-permission_request, der ausgelöst wird, während nur die Reply-Pump angehängt ist, an diesen Stream ROUTED und von der Pump verworfen (sie leitet nur JSON-RPC-Responses weiter), was den Mediator aufhängt, wohingegen der Daemon bei gar keinem Stream Cancel-Denies ausführt und der Agent fortfährt. Sowohl die Daemon-seitige Unterscheidung “Ist das ein echter Consumer oder nur eine Reply-Pump?” als auch die SDK-seitige Behandlung (lokal ablehnen / an einen Permission-Callback weitergeben) gehören in dasselbe Permission-Koordinations-Follow-up, da die Pump selbst kein Vote abgeben kann. Consumer, die Permission-Handling benötigen, solltensubscribeEventsöffnen, bevor sie Session-RPCs ausgeben (der dokumentierte Vertrag), was dem Daemon einen echten Consumer-Stream gibt. - Session-RPCs, die innerhalb der
subscribeEvents-Schleife auf dem exportiertenAcpHttpTransportausgegeben werden. Der Session-/acp-Stream ist Single-Reader: Während der Async-Generator eines Consumers zwischenyields parkt, drainiert der Reader nicht. Wenn der Consumer innerhalb seiner eigenen Event-Handling-Schleife auf ein Session-geroutetes RPC (session/set_model,session/prompt, …)awaitet, unterdrücktsendRequestdie Background-Reply-Pump (ein Abonnement ist “aktiv”), aber der geparkte Generator liest die Reply nie – der Aufruf hängt, bis der Consumer das nächste Event pullt. Der robuste Fix besteht darin, den Session-Reader zu einer Background-Pump zu machen, die immer JSON-RPC-Replies drainiert und nurDaemonEvents für den Iterator queued; verschoben als fokussiertes Follow-up, da es sich um eine strukturelle Änderung an einem Opt-in, neu exportierten Transport handelt und den Standard-REST-Transport nicht beeinflusst. - Automatischer Guard für die
SESSION_STREAM_REPLY_METHODS⇄replySession-Drift. Das SDK-SetSESSION_STREAM_REPLY_METHODSmuss diereplySession(...)-Call-Sites des Daemons indispatch.ts(ein anderes Package) spiegeln; eine Methode, die dort hinzugefügt wird, ohne sie hier hinzuzufügen, öffnet keine Reply-Pump und ein No-Subscriber-sendRequestdafür hängt bis zum Abbruch. Das Typsystem keines der beiden Packages erzwingt dies. Ein CI-Guard (ein leichtgewichtiges Skript oder Vitest, das die Session-Reply-Methodennamen des Daemons extrahiert und sie mit dem SDK-Set diffed) ist der richtige Fix, aber Cross-Package-Static-Analysis-Tooling ist eine eigene fokussierte Aufgabe – und kein triviales Grep: Ein korrekter Extraktor benötigt eine leichte Dataflow-Analyse, da die Reply vonsession/promptNICHT innerhalb seinescase 'session/prompt'-Blocks ausgegeben wird. Der Prompt wird asynchron gestartet und seinreplySession(...)feuert später vom Prompt-Completion-Handler (einer anderen Call-Site), sodass ein naiver Scan “Welchecase-Blöcke enthaltenreplySession”session/promptfälschlicherweise AUSKLAMMERN würde und den Build gegen ein korrektes Set fehlschlagen lässt. Das Set ist in der Zwischenzeit klein und stabil, und die JSDoc der Konstante dokumentiert die Invariante; der robuste Langzeit-Fix besteht darin, dass der Daemon seine Session-gerouteten Methodennamen bekannt gibt (eine gemeinsame Source of Truth), anstattdispatch.tszu scrapen.