-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat(daemon): ACP Streamable HTTP transport at /acp [RFD #721] #4472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
chiga0
merged 46 commits into
QwenLM:daemon_mode_b_main
from
chiga0:feat/daemon-acp-http-streamable
May 27, 2026
Merged
Changes from all commits
Commits
Show all changes
46 commits
Select commit
Hold shift + click to select a range
f7ce806
fix(serve): post-merge fixes for #4291 review (7 threads) (#4305)
doudouOUC 066cab2
fix(serve): post-merge P2 corrections from Codex review on #4282 (#4297)
doudouOUC 981bc7c
feat(acp-bridge): F1 — acp-bridge package self-sufficiency (#4175 mec…
doudouOUC dfa8ca4
feat(serve): F1 follow-up — BridgeFileSystem wiring + #4325 channelIn…
doudouOUC 8eeb510
feat(acp-bridge): F3 — multi-client permission coordination (#4175) (…
doudouOUC a60c1c5
feat(serve+sdk): F4 prereq — daemon protocol completion (serverTimest…
doudouOUC 46f8d48
feat(serve): shared MCP transport pool [F2] (#4336)
doudouOUC d0563ec
feat(daemon): add shared UI transcript layer (#4328)
chiga0 c6deb58
perf(core): F2 cleanup PR A — R9/W11/W12/R10 (post-merge follow-ups) …
doudouOUC 57d0478
refactor(acp-bridge): F1 test split — lift bridge.test.ts (6861 LOC) …
doudouOUC 0c04309
fix(core): F2 cleanup PR B — self-heal observability (W133-a + W134) …
doudouOUC cf5c245
feat(sdk/daemon-ui): unified completeness follow-up to #4328 (#4353)
chiga0 a9d0c5f
chore(integration): sync main into daemon_mode_b_main (2026-05-24) (#…
doudouOUC 63803de
docs(serve): v0.16-alpha known limits + SDK QWEN_SERVER_TOKEN env fal…
doudouOUC 74c5d45
docs(deploy): local launch templates for v0.16-alpha (PR 30a) (#4483)
doudouOUC 817f042
feat(daemon+sdk): cross-client real-time sync completeness (#4484)
chiga0 81b46c2
chore(integration): sync main into daemon_mode_b_main (2026-05-25) (#…
doudouOUC 3363f9d
feat(serve): add POST /session/:id/recap (#4504)
doudouOUC 01d08e0
docs(daemon): design — ACP-over-HTTP → official ACP Streamable HTTP t…
a9b3f5b
feat(daemon): ACP Streamable HTTP transport at /acp (RFD #721)
200c355
fix(daemon): ACP-HTTP review round 2 — reconnect, ownership, leaks, n…
d034c72
fix(daemon): ACP-HTTP review round 3 — PR bot fold-ins (#4472)
d79d14c
fix(daemon): ACP-HTTP review round 4 — write-failure ownership, loopb…
57fcb49
fix(daemon): ACP-HTTP review round 5 — permission-vote release + disc…
269499a
fix(daemon): ACP-HTTP review round 6 — concurrent-prompt abort, zombi…
dd3139d
fix(daemon): ACP-HTTP review round 7 — close TOCTOU, pump lifecycle, …
29cf206
feat(daemon): ACP-HTTP REST parity batch + official extension scheme …
66fe560
fix(daemon): ACP-HTTP review round 9 — reconnect prompt survival + ca…
94eb520
docs(daemon): record ACP-HTTP follow-up roadmap (acp-bridge补齐 + /acp …
271e059
fix(daemon): ACP-HTTP — deliver id'd response when session closes mid…
0d77d91
fix(daemon): ACP-HTTP round-10 review batch — validation, orphan-guar…
ae263c3
refactor(daemon): ACP-HTTP vendor namespace _qwen.ai/ -> _qwen/
abe5564
refactor(daemon): ACP-HTTP — replace bridge-boundary 'as never' with …
476e34c
fix(daemon): ACP-HTTP review round 11 — orphan-from-disk, permission …
6027fdd
fix(daemon): ACP-HTTP review round 12 — close/load TOCTOU, permission…
0ebaf53
fix(daemon): ACP-HTTP review round 13 — close/load post-await re-chec…
8632ed4
fix(serve): /acp R15 — mediator-stuck diagnostics + retryable close-r…
92d5332
fix(vscode-ide-companion): exclude workspace packages from NOTICES.tx…
yiliang114 77333a4
fix(telemetry): attach interaction span to session root context (#4499)
doudouOUC d3a81ee
fix(serve): /acp R16 — REST-parity validation, listener cleanup, safe…
9723f57
Merge remote-tracking branch 'origin/main' into feat/daemon-acp-http-…
ce53283
fix(cli): auto-prepend @ when pasting or dropping multiple file paths…
MikeWang0316tw d3c9d56
Merge remote-tracking branch 'origin/main' into feat/daemon-acp-http-…
1d953c5
Merge remote-tracking branch 'origin/daemon_mode_b_main' into feat/da…
4297bd0
fix(merge): remove duplicate runTTLSweepForTesting + prettier format
470e418
fix(acp): address R17 review findings
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,322 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import type { Application, Request, Response } from 'express'; | ||
| import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; | ||
| import { writeStderrLine } from '../../utils/stdioHelpers.js'; | ||
| import { AcpDispatcher } from './dispatch.js'; | ||
| import { ConnectionRegistry } from './connectionRegistry.js'; | ||
| import { SseStream } from './sseStream.js'; | ||
| import { RPC, error as rpcError, isRequest, parseInbound } from './jsonRpc.js'; | ||
|
|
||
| export const ACP_CONNECTION_HEADER = 'acp-connection-id'; | ||
| export const ACP_SESSION_HEADER = 'acp-session-id'; | ||
|
|
||
| /** | ||
| * Grace window after the connection-scoped SSE stream closes before the | ||
| * connection is reaped (if not reconnected and no session stream is live). | ||
| * Long enough to ride out a transient blip / reconnect, short enough to free | ||
| * `ownedSessions` + a `maxConnections` slot well before the 30-min idle TTL. | ||
| */ | ||
| const CONN_GRACE_MS = 10_000; | ||
|
|
||
| export interface MountAcpHttpOptions { | ||
| boundWorkspace: string; | ||
| /** Defaults to `process.env.QWEN_SERVE_ACP_HTTP !== '0'`. */ | ||
| enabled?: boolean; | ||
| /** Mount path; defaults to `/acp`. */ | ||
| path?: string; | ||
| /** Concurrent-connection cap; `0` disables. Defaults to the registry default. */ | ||
| maxConnections?: number; | ||
| } | ||
|
|
||
| export interface AcpHttpHandle { | ||
| dispose(): void; | ||
| registry: ConnectionRegistry; | ||
| } | ||
|
|
||
| /** | ||
| * Mount the official ACP Streamable HTTP transport (RFD #721) on an | ||
| * existing Express app, backed by the shared `HttpAcpBridge`. Additive: | ||
| * the REST surface (`/session/*`) is untouched (design doc §6). | ||
| * | ||
| * Wire shape (single `/acp` endpoint): | ||
| * - POST {initialize} → 200 + capabilities JSON + `Acp-Connection-Id` | ||
| * - POST {other} → 202; reply delivered on a long-lived SSE stream | ||
| * - GET (conn header) → connection-scoped SSE stream | ||
| * - GET (conn+session)→ session-scoped SSE stream | ||
| * - DELETE → 202; tears the connection down | ||
| */ | ||
| export function mountAcpHttp( | ||
| app: Application, | ||
| bridge: HttpAcpBridge, | ||
| opts: MountAcpHttpOptions, | ||
| ): AcpHttpHandle | undefined { | ||
| const enabled = opts.enabled ?? process.env['QWEN_SERVE_ACP_HTTP'] !== '0'; | ||
| if (!enabled) return undefined; | ||
|
|
||
| const path = opts.path ?? '/acp'; | ||
| const dispatcher = new AcpDispatcher(bridge, opts.boundWorkspace); | ||
| // When a session/connection tears down with a permission still pending, | ||
| // cancel it on the bridge so the agent's prompt isn't left blocked. | ||
| const registry = new ConnectionRegistry( | ||
| (req, clientId) => dispatcher.cancelAbandonedPermission(req, clientId), | ||
| // Best-effort bridge detach so a torn-down connection's bridge-stamped | ||
| // client ids don't linger in the bridge's voter/known-client sets. | ||
| (sessionId, clientId) => { | ||
| void bridge.detachClient(sessionId, clientId).catch((err: unknown) => { | ||
| writeStderrLine( | ||
| `qwen serve: /acp detachClient(${sessionId}) failed: ${ | ||
| err instanceof Error ? err.message : String(err) | ||
| }`, | ||
| ); | ||
| }); | ||
| }, | ||
| opts.maxConnections, | ||
| ); | ||
|
|
||
| // ── POST /acp ────────────────────────────────────────────────────── | ||
| app.post(path, async (req: Request, res: Response) => { | ||
| const parsed = parseInbound(req.body); | ||
| if (!parsed.ok) { | ||
| writeStderrLine( | ||
| `qwen serve: /acp malformed request from ${req.socket?.remoteAddress}: ${parsed.error.error.message}`, | ||
| ); | ||
| res.status(400).json(parsed.error); | ||
| return; | ||
| } | ||
| const message = parsed.message; | ||
|
|
||
| // `initialize` mints a connection and replies inline (200 + JSON). | ||
| if (isRequest(message) && message.method === 'initialize') { | ||
| const conn = registry.create(isLoopbackReq(req)); | ||
| if (!conn) { | ||
| // Connection cap reached — shed load rather than grow unbounded. | ||
| writeStderrLine( | ||
| `qwen serve: /acp connection cap reached (max=${registry.connectionCap}), rejecting initialize`, | ||
| ); | ||
| res.setHeader('Retry-After', '5'); | ||
| res | ||
| .status(503) | ||
| .json( | ||
| rpcError( | ||
| message.id, | ||
| RPC.INTERNAL_ERROR, | ||
| 'Too many ACP connections; retry later', | ||
| ), | ||
| ); | ||
| return; | ||
| } | ||
| const requestedVersion = | ||
| message.params && | ||
| typeof message.params === 'object' && | ||
| !Array.isArray(message.params) | ||
| ? (message.params as Record<string, unknown>)['protocolVersion'] | ||
| : undefined; | ||
| res.setHeader('Acp-Connection-Id', conn.connectionId); | ||
| res.status(200).json({ | ||
| // success envelope: clients correlate by the request id. | ||
| jsonrpc: '2.0', | ||
| id: message.id, | ||
| result: dispatcher.buildInitializeResult( | ||
| conn.connectionId, | ||
| requestedVersion, | ||
| ), | ||
| }); | ||
| writeStderrLine( | ||
| `qwen serve: /acp connection established ${conn.connectionId.slice(0, 8)} ` + | ||
| `(loopback=${conn.fromLoopback}, active=${registry.size})`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| const conn = registry.get(headerOf(req, ACP_CONNECTION_HEADER)); | ||
| if (!conn) { | ||
| res | ||
| .status(400) | ||
| .json( | ||
| rpcError( | ||
| isRequest(message) ? message.id : null, | ||
| RPC.INVALID_REQUEST, | ||
| 'Missing or unknown Acp-Connection-Id', | ||
| ), | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| // Per RFD: non-initialize POST acks 202; the reply rides an SSE stream. | ||
| res.status(202).end(); | ||
| // Response already sent — `handle` delivers everything else over SSE, so | ||
| // swallow+log any late rejection rather than let it escape as an | ||
| // unhandled rejection (which could take the daemon down). | ||
| await dispatcher | ||
| .handle( | ||
| conn, | ||
| message, | ||
| headerOf(req, ACP_SESSION_HEADER), | ||
| isLoopbackReq(req), | ||
| ) | ||
| .catch((err: unknown) => { | ||
| writeStderrLine( | ||
| `qwen serve: /acp handle error: ${ | ||
| err instanceof Error ? err.message : String(err) | ||
| }`, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| // ── GET /acp (SSE) ───────────────────────────────────────────────── | ||
| app.get(path, (req: Request, res: Response) => { | ||
| const conn = registry.get(headerOf(req, ACP_CONNECTION_HEADER)); | ||
| if (!conn) { | ||
| res.status(400).json({ error: 'Missing or unknown Acp-Connection-Id' }); | ||
| return; | ||
| } | ||
| const sessionId = headerOf(req, ACP_SESSION_HEADER); | ||
|
|
||
| if (!sessionId) { | ||
| // Connection-scoped stream. onClose logs the disconnect so a | ||
| // half-dead connection (conn stream gone, replies silently buffering) | ||
| // leaves an operator breadcrumb. | ||
| const connId = conn.connectionId; | ||
| const stream = new SseStream( | ||
| res, | ||
| () => { | ||
| writeStderrLine( | ||
| `qwen serve: /acp connection stream closed (${connId.slice(0, 8)})`, | ||
| ); | ||
| // Grace-period reap: a dead connection otherwise locks its | ||
| // ownedSessions + counts against maxConnections for the full 30-min | ||
| // idle TTL. After the grace window, reap UNLESS a reconnect | ||
| // re-attached the conn stream (clears the timer) OR a session | ||
| // stream is still live (client is active — only the conn stream | ||
| // blipped, don't kill its sessions/prompts). | ||
| conn.clearGraceTimer(); | ||
| conn.connGraceTimer = setTimeout(() => { | ||
| if ( | ||
| registry.get(connId) === conn && | ||
| conn.connStream === stream && | ||
| !conn.hasLiveSessionStream() | ||
| ) { | ||
| writeStderrLine( | ||
| `qwen serve: /acp reaping connection ${connId.slice(0, 8)} (conn stream gone, no live session stream)`, | ||
| ); | ||
| registry.delete(connId); | ||
| } | ||
| }, CONN_GRACE_MS); | ||
| conn.connGraceTimer.unref?.(); | ||
| }, | ||
| () => conn.touch(), | ||
| ); | ||
| stream.open(); | ||
| conn.attachConnStream(stream); | ||
| return; | ||
| } | ||
|
|
||
| // Session-scoped stream — only for a session THIS connection owns | ||
| // (created via session/new or attached via session/load|resume). Stops | ||
| // one connection eavesdropping on another's session event stream. | ||
| if (!conn.ownsSession(sessionId)) { | ||
| res.status(403).json({ error: 'Session not owned by this connection' }); | ||
| return; | ||
| } | ||
|
|
||
| // Fresh controller per stream so a reconnect gets a live (non-aborted) | ||
| // signal; `attachSessionStream` installs it and tears down any prior | ||
| // stream/subscription. onClose aborts THIS stream's controller — a | ||
| // stale stream closing can't cancel a newer subscription. | ||
| const ac = new AbortController(); | ||
| const stream = new SseStream( | ||
| res, | ||
| () => { | ||
| // Stream closed (tab close / network drop / crash): stop the event | ||
| // pump AND abort any in-flight prompt for this session — otherwise | ||
| // the agent keeps running (quota, FIFO) until idle TTL. | ||
| ac.abort(); | ||
| // BUT only abort the prompt when THIS is still the session's live | ||
| // stream. A reconnect already installed a newer stream — the prompt | ||
| // must survive the old stream's close. CONTRACT: this identity guard | ||
| // pairs with `attachSessionStream`'s install-before-close ordering | ||
| // (connectionRegistry.ts) — keep both in lockstep. | ||
| if (conn.sessions.get(sessionId)?.stream === stream) { | ||
| conn.sessions.get(sessionId)?.promptAbort?.abort(); | ||
| } | ||
| }, | ||
| () => conn.touch(), | ||
| ); | ||
| // Open (write SSE headers + `retry:`) BEFORE attaching, so the protocol | ||
| // handshake precedes any buffered frames the attach flushes. | ||
| stream.open(); | ||
| conn.attachSessionStream(sessionId, stream, ac); | ||
| // Identity-guarded close: only tear down if THIS stream is still the | ||
| // session's current one (a reconnect between settle and this microtask | ||
| // would otherwise kill the fresh stream). | ||
| const closeIfCurrent = () => { | ||
| if (conn.sessions.get(sessionId)?.stream === stream) { | ||
| conn.closeSessionStream(sessionId); | ||
| } | ||
| }; | ||
| void dispatcher.pumpSessionEvents(conn, sessionId, ac.signal).then( | ||
| // NORMAL completion (iterator returned `done` — subprocess ended): close | ||
| // so the stream isn't a zombie heartbeating with nothing left to deliver. | ||
| closeIfCurrent, | ||
| (err: unknown) => { | ||
| writeStderrLine( | ||
| `qwen serve: /acp event pump error (${sessionId}): ${ | ||
| err instanceof Error ? err.message : String(err) | ||
| }`, | ||
| ); | ||
| closeIfCurrent(); | ||
| }, | ||
| ); | ||
| }); | ||
|
|
||
| // ── DELETE /acp ──────────────────────────────────────────────────── | ||
| app.delete(path, (req: Request, res: Response) => { | ||
| const connectionId = headerOf(req, ACP_CONNECTION_HEADER); | ||
| if (!connectionId) { | ||
| res.status(400).json({ error: 'Missing Acp-Connection-Id' }); | ||
| return; | ||
| } | ||
| // NOTE: like every other route, DELETE is gated only by the bearer | ||
| // token — the daemon's trust boundary is "holds the token for this | ||
| // single-workspace daemon", so any token-holder may tear down any | ||
| // connection (same posture as the REST `DELETE /session/:id`). A | ||
| // per-connection secret would add intra-token isolation; deferred with | ||
| // the rest of the multi-tenant hardening (design §7). | ||
| const existed = registry.delete(connectionId); | ||
| if (existed) { | ||
| writeStderrLine( | ||
| `qwen serve: /acp connection deleted ${connectionId.slice(0, 8)} (remaining=${registry.size})`, | ||
| ); | ||
| } | ||
| res.status(202).end(); | ||
| }); | ||
|
|
||
| return { dispose: () => registry.dispose(), registry }; | ||
| } | ||
|
|
||
| function headerOf(req: Request, name: string): string | undefined { | ||
| const v = req.headers[name]; | ||
| return Array.isArray(v) ? v[0] : v; | ||
| } | ||
|
|
||
| /** | ||
| * True when the request's KERNEL-stamped peer address is loopback. Mirrors | ||
| * the REST surface's `detectFromLoopback` (NOT derived from forgeable | ||
| * headers like `X-Forwarded-For`). Replicated here rather than imported | ||
| * from `server.ts` to avoid a server↔acpHttp import cycle. | ||
| */ | ||
| function isLoopbackReq(req: Request): boolean { | ||
| const addr = req.socket?.remoteAddress; | ||
| if (typeof addr !== 'string') return false; | ||
| // Match the REST surface's `detectFromLoopback`: the full 127.0.0.0/8 | ||
| // range + the IPv4-mapped block, not just three exact literals (a | ||
| // container peer on 127.0.0.2 is legal loopback). | ||
| return ( | ||
| addr === '::1' || addr.startsWith('127.') || addr.startsWith('::ffff:127.') | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import { describe, expect, it } from 'vitest'; | ||
| import { | ||
| isNotification, | ||
| isRequest, | ||
| isResponse, | ||
| parseInbound, | ||
| QWEN_METHOD_NS, | ||
| RPC, | ||
| } from './jsonRpc.js'; | ||
|
|
||
| describe('jsonRpc helpers', () => { | ||
| it('classifies a request', () => { | ||
| const m = { jsonrpc: '2.0', id: 1, method: 'initialize' }; | ||
| expect(isRequest(m)).toBe(true); | ||
| expect(isNotification(m)).toBe(false); | ||
| expect(isResponse(m)).toBe(false); | ||
| }); | ||
|
|
||
| it('classifies a notification (no id)', () => { | ||
| const m = { jsonrpc: '2.0', method: 'session/cancel' }; | ||
| expect(isNotification(m)).toBe(true); | ||
| expect(isRequest(m)).toBe(false); | ||
| }); | ||
|
|
||
| it('classifies a response (result, no method)', () => { | ||
| const m = { jsonrpc: '2.0', id: -1, result: { ok: true } }; | ||
| expect(isResponse(m)).toBe(true); | ||
| expect(isRequest(m)).toBe(false); | ||
| }); | ||
|
|
||
| it('classifies an error response', () => { | ||
| const m = { jsonrpc: '2.0', id: 2, error: { code: -1, message: 'x' } }; | ||
| expect(isResponse(m)).toBe(true); | ||
| }); | ||
|
|
||
| it('rejects a response with BOTH result and error (XOR); parseInbound → 400-shape', () => { | ||
| const m = { | ||
| jsonrpc: '2.0', | ||
| id: 3, | ||
| result: {}, | ||
| error: { code: -1, message: 'x' }, | ||
| }; | ||
| expect(isResponse(m)).toBe(false); | ||
| const r = parseInbound(m); | ||
| expect(r.ok).toBe(false); | ||
| if (!r.ok) expect(r.error.error.code).toBe(RPC.INVALID_REQUEST); | ||
| }); | ||
|
|
||
| it('rejects JSON-RPC batch arrays', () => { | ||
| const r = parseInbound([{ jsonrpc: '2.0', id: 1, method: 'x' }]); | ||
| expect(r.ok).toBe(false); | ||
| if (!r.ok) expect(r.error.error.code).toBe(RPC.INVALID_REQUEST); | ||
| }); | ||
|
|
||
| it('rejects malformed envelopes', () => { | ||
| expect(parseInbound({ foo: 'bar' }).ok).toBe(false); | ||
| expect(parseInbound(null).ok).toBe(false); | ||
| }); | ||
|
|
||
| it('accepts a well-formed request', () => { | ||
| const r = parseInbound({ jsonrpc: '2.0', id: 1, method: 'session/new' }); | ||
| expect(r.ok).toBe(true); | ||
| }); | ||
|
|
||
| it('exposes the qwen extension namespace', () => { | ||
| expect(QWEN_METHOD_NS).toBe('_qwen/'); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] When the 10s grace timer reaps a connection, any in-flight prompt is silently aborted by
destroy(). The error frame is buffered into an orphanedconnBufferthat will never be flushed. The operator sees the reap log but has no indication that a prompt was killed — the client's prompt "disappears" with no correlating server-side breadcrumb.Before calling
registry.delete(connId), check for and log sessions with in-flight prompts:— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No-fix (logging enhancement, not a correctness issue). The reap is already logged (
reaping connection ...). The client's prompt abort surfaces as a closed SSE stream + HTTP-level disconnect. Adding per-session prompt logging on reap is a nice-to-have for future observability work but doesn't change behavior.