Skip to content

Commit d79d14c

Browse files
秦奇qwencoder
andcommitted
fix(daemon): ACP-HTTP review round 4 — write-failure ownership, loopback, error routing
Rebased onto daemon_mode_b_main (QwenLM#4353 + QwenLM#4469), no conflicts. Addresses the PR reviewers: - C1 (P0): SseStream now OWNS write-failure handling (log + close on first reject; 'error' listener in doWrite; guarded onClose) — the round-3 note claimed this but it wasn't implemented. - C2 (P1): per-request fromLoopback threaded into sessionCtx/permission votes; isLoopbackReq widened to 127.0.0.0/8 + ::ffff:127.* + ::1 (REST parity). - C3 (P1): CONN_ROUTED_METHODS — route error frames like the success path (no misroute of session/load|resume|close|heartbeat failures). - C4 (P1): bridge.detachClient on connection/session teardown (no stale bridge client ids). - C5 (P1): session/close local cleanup in finally. - C6-C11 (P2): path.isAbsolute cwd (Windows); protocolVersion clamp [1,1]; reject empty load/resume sessionId; log notification-form prompt errors; open() before session-stream attach; shared writeStderrLine. - C12 (P2): design doc aligned to shipped surface (env toggle only; fs/*, terminal/*, --no-acp-http flag, acp_http capability tag marked deferred). Suite 22 -> 25 tests. Re-verified live (125 session/update -> end_turn). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent d034c72 commit d79d14c

6 files changed

Lines changed: 272 additions & 52 deletions

File tree

docs/design/daemon-acp-http/README.md

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,14 @@ No change to `bridge.ts` / `eventBus.ts` (additive consumer only).
216216
`AcpConnection` keeps `Map<jsonRpcId, {sessionId, kind, bridgeRequestId, resolve}>`.
217217
When the client POSTs a JSON-RPC response object, `dispatch` matches `id`, then calls the
218218
bridge resolution path (e.g. permission `POST /session/:id/permission/:requestId`
219-
internal equivalent). `fs/*` requests: the daemon already satisfies file reads via its
220-
own workspace FS for the REST path; for the ACP surface we **forward** `fs/*` to the
221-
client (true ACP semantics) and only fall back to local FS when the client lacks the
222-
`fs` capability (declared in `initialize`).
219+
internal equivalent).
220+
221+
> **v1 status:** only the `session/request_permission` agent→client round-trip is
222+
> implemented. `fs/*` and `terminal/*` agent→client forwarding is **deferred** (§7) — the
223+
> daemon does not yet advertise `fs`/`terminal` client-capability negotiation on `/acp`,
224+
> so ACP clients should not assume filesystem/terminal semantics over this transport in
225+
> v1. The intended end state (forward `fs/*` to the client; fall back to the daemon's
226+
> workspace FS when the client lacks the `fs` capability) is the follow-up described in §7.
223227
224228
---
225229

@@ -259,8 +263,10 @@ Standard methods are **never** renamed; extensions are strictly additive and ign
259263
- Both transports share **one** `HttpAcpBridge` + `EventBus` instance, so there is no
260264
state duplication — `/acp` and `/session/*` can even drive the same live session
261265
concurrently (multi-client is already supported by the bridge).
262-
- Toggle: on by default; `QWEN_SERVE_ACP_HTTP=0` (or `--no-acp-http`) disables mount.
263-
Advertised via a `acp_http` tag in `/capabilities`.
266+
- Toggle (v1, shipped): on by default; **`QWEN_SERVE_ACP_HTTP=0`** disables the mount. A
267+
`--no-acp-http` CLI flag and an `acp_http` tag in `/capabilities` for client feature-
268+
detection are **deferred** to a follow-up (not in v1) — until then clients detect the
269+
transport by probing `POST /acp {initialize}`.
264270

265271
Migration path: once the RFD ratifies and SDKs ship, REST routes can be reframed as a
266272
thin compat shim over `/acp` (separate, later PR).
@@ -420,3 +426,30 @@ touched here.
420426

421427
**Still deferred** (documented): per-connection secret for `DELETE`/connection ownership (token remains
422428
the boundary); WebSocket + HTTP/2 (§7); strict prompt-result vs trailing-update barrier (§11).
429+
430+
---
431+
432+
## 13. Review round 4 — PR fold-ins (rebased onto #4469)
433+
434+
Branch rebased onto `daemon_mode_b_main` (#4353 + #4469) — **clean, no conflicts**. Two PR
435+
reviewers (GPT-5 + qwen3.7-max). Suite now **25 tests**; live re-verified (125 `session/update`
436+
`end_turn`).
437+
438+
| # | Severity | Finding | Fix |
439+
|---|----------|---------|-----|
440+
| C1 | **P0** | Round-3 "SSE write-failure handling" was documented but NOT implemented — `SseStream` still left it to discarding callers (zombie streams). | `writeRaw` now owns it: first write rejection logs once + `close()`s; `doWrite` also listens for `'error'` (rejects promptly instead of hanging to `'close'`); `onClose` wrapped in try/catch. |
441+
| C2 | **P1** | `fromLoopback` captured only at `initialize` + helper narrower than REST → `local-only` votes from a later POST misjudged. | Per-request loopback threaded through `handle``sessionCtx`/`resolveClientResponse`; `isLoopbackReq` widened to `127.0.0.0/8` + `::ffff:127.*` + `::1` (matches REST). |
442+
| C3 | **P1** | Error routing inferred stream from `params.sessionId` → conn-scoped method failures (`session/load`/`resume`/`close`/`heartbeat`) misrouted to a non-existent session stream (silent loss). | `CONN_ROUTED_METHODS` set; errors route the same way as the success path. |
443+
| C4 | **P1** | `bridge.detachClient` never called on teardown → stale bridge-stamped client ids linger in `knownClientIds()`/voter sets. | Registry takes a `DetachSessionFn`; `closeSessionStream`/`destroy` detach each owned session (best-effort). |
444+
| C5 | **P1** | `session/close` skipped local cleanup if `bridge.closeSession` threw. | `closeSessionStream` moved into a `finally`. |
445+
| C6 | **P2** | Windows `cwd` (`C:\…`) rejected by `startsWith('/')`. | `path.isAbsolute` (platform-aware), matching REST. |
446+
| C7 | **P2** | `protocolVersion` could negotiate `0`/negative. | Clamp `Math.max(1, Math.min(requested, 1))`; tests for 0/neg/huge/invalid. |
447+
| C8 | **P2** | `session/load`/`resume` accepted empty `sessionId`. | Reject empty with `INVALID_PARAMS`. |
448+
| C9 | **P2** | Notification-form `session/prompt` errors vanished silently. | Log on the no-id path. |
449+
| C10 | **P2** | Session SSE flushed buffered frames before headers/`retry:`. | `open()` before `attachSessionStream`. |
450+
| C11 | **P2** | Duplicate local `logStderr`. | Shared `writeStderrLine` from `utils/stdioHelpers`. |
451+
| C12 | **P2** | Docs advertised `--no-acp-http` flag, `acp_http` capability tag, and `fs/*` forwarding not in v1. | Doc aligned to shipped surface (env-var toggle only; `fs/*`+`terminal/*` + flag + tag marked deferred). |
452+
453+
Still deferred (unchanged): WebSocket + HTTP/2; per-connection secret for `DELETE`/ownership
454+
(token + single-workspace remains the boundary); strict prompt-result ordering barrier; the
455+
`as never` bridge-boundary casts (targeted, noted for an adapter-types follow-up).

packages/cli/src/serve/acpHttp/connectionRegistry.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import { randomUUID } from 'node:crypto';
8+
import { writeStderrLine } from '../../utils/stdioHelpers.js';
89
import type { SseStream } from './sseStream.js';
910

1011
/**
@@ -18,10 +19,6 @@ const MAX_BUFFERED_FRAMES = 256;
1819
/** Default cap on concurrent live connections (mirrors a bounded resource). */
1920
const DEFAULT_MAX_CONNECTIONS = 64;
2021

21-
function logStderr(line: string): void {
22-
process.stderr.write(`${line}\n`);
23-
}
24-
2522
/**
2623
* Invoked when a session/connection tears down while an agent→client
2724
* request (e.g. a permission prompt) is still outstanding, so the bridge
@@ -32,6 +29,18 @@ export type AbandonPendingFn = (
3229
clientId: string | undefined,
3330
) => void;
3431

32+
/**
33+
* Best-effort bridge detach for a session's bridge-stamped clientId on
34+
* teardown. Without it, `session/new`/`load`/`resume`-registered client ids
35+
* stay visible in `knownClientIds()`/`votersForSession()` after the ACP
36+
* connection is gone — skewing permission mediation + origin validation.
37+
* ACP clients can't clean this up themselves (the id isn't on the wire).
38+
*/
39+
export type DetachSessionFn = (
40+
sessionId: string,
41+
clientId: string | undefined,
42+
) => void;
43+
3544
/**
3645
* Tracks one logical ACP-over-HTTP connection (RFD #721). A connection is
3746
* minted at `initialize`, keyed by `Acp-Connection-Id`, and may host many
@@ -110,6 +119,7 @@ export class AcpConnection {
110119
connectionId: string | undefined,
111120
fromLoopback: boolean,
112121
private readonly onAbandonPending?: AbandonPendingFn,
122+
private readonly onDetachSession?: DetachSessionFn,
113123
) {
114124
this.connectionId = connectionId ?? randomUUID();
115125
this.clientId = randomUUID();
@@ -203,6 +213,7 @@ export class AcpConnection {
203213
binding.promptAbort?.abort();
204214
binding.stream?.close();
205215
this.abandonPendingForSession(sessionId, binding.clientId);
216+
this.onDetachSession?.(sessionId, binding.clientId);
206217
this.sessions.delete(sessionId);
207218
this.ownedSessions.delete(sessionId);
208219
}
@@ -213,6 +224,9 @@ export class AcpConnection {
213224
binding.promptAbort?.abort();
214225
binding.stream?.close();
215226
this.abandonPendingForSession(binding.sessionId, binding.clientId);
227+
// Release the bridge-stamped clientId so it doesn't linger in the
228+
// bridge's voter/known-client sets after this connection is gone.
229+
this.onDetachSession?.(binding.sessionId, binding.clientId);
216230
}
217231
this.sessions.clear();
218232
this.ownedSessions.clear();
@@ -249,6 +263,7 @@ export class ConnectionRegistry {
249263

250264
constructor(
251265
private readonly onAbandonPending?: AbandonPendingFn,
266+
private readonly onDetachSession?: DetachSessionFn,
252267
private readonly maxConnections = DEFAULT_MAX_CONNECTIONS,
253268
private readonly idleTtlMs = 30 * 60_000,
254269
) {
@@ -265,7 +280,12 @@ export class ConnectionRegistry {
265280
if (this.maxConnections > 0 && this.byId.size >= this.maxConnections) {
266281
return undefined;
267282
}
268-
const conn = new AcpConnection(undefined, fromLoopback, this.onAbandonPending);
283+
const conn = new AcpConnection(
284+
undefined,
285+
fromLoopback,
286+
this.onAbandonPending,
287+
this.onDetachSession,
288+
);
269289
this.byId.set(conn.connectionId, conn);
270290
return conn;
271291
}
@@ -301,7 +321,7 @@ export class ConnectionRegistry {
301321
// streams is otherwise invisible to operators chasing "my client
302322
// froze". Note that `touch()` fires on inbound HTTP AND on event
303323
// delivery (pumpSessionEvents), so a long quiet prompt isn't reaped.
304-
logStderr(
324+
writeStderrLine(
305325
`qwen serve: /acp reaping idle connection ${id} ` +
306326
`(idle > ${Math.round(this.idleTtlMs / 60_000)}m, ` +
307327
`${conn.sessions.size} session(s))`,

0 commit comments

Comments
 (0)