docs(serve): reconcile recap cancellation docs with actual v1 behavior

Per chiga0's review on #4504 (option 1 — match docs to reality rather
than wire up cosmetic AbortController plumbing). The route, design doc,
and protocol reference all claimed "client disconnect aborts the
bridge-side wait" via `res.once('close')`, but the route has no such
listener and the bridge accepts no `AbortSignal`. The only ceilings
are the 60s `SESSION_RECAP_TIMEOUT_MS` backstop and the transport-
closed race against ACP channel death.

Wiring an HTTP-side AbortController in isolation would be cosmetic
because the ACP child handler also passes a never-aborting
`AbortController().signal` to the core helper (no cross-process abort
plumbing yet) — e2e cancel needs both layers. Recap is short (~1–5s,
`maxOutputTokens: 300`), so the absent cancellation is acceptable for
v1; a request-id-based cancel ext-method can land in a follow-up.

Also adds two known-limit bullets to the user guide per chiga0's other
minor notes: token-cost amplification on no-token loopback (no
per-route rate limit) and concurrent-recap safety (side-query reads
chat history via `GeminiClient.getChat().getHistory()` snapshot and
runs through a separate `BaseLlmClient`, never mutating the session's
`GeminiChat`).

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
This commit is contained in:
doudouOUC 2026-05-25 20:41:19 +08:00
parent 01323fda75
commit 058bde70f9
4 changed files with 34 additions and 11 deletions

View file

@ -44,11 +44,19 @@ helpers: `DaemonClient.recapSession(sessionId, opts)` and
`docs/developers/qwen-serve-protocol.md` § `POST /session/:id/recap`
for the wire contract and error envelope.
Cancellation is best-effort at v1: client disconnect aborts the
bridge-side wait, but the LLM call in the ACP child runs to completion
(recap is short — single-attempt, ~15s typical). A 60s backstop
timeout guards a wedged ACP channel. A future request-id-based cancel
ext-method can plumb full end-to-end cancellation if needed.
Cancellation is **absent in v1**. The route does not listen for HTTP
client disconnect, no `AbortSignal` is threaded into
`bridge.generateSessionRecap`, and the ACP child handler passes a
never-aborting `AbortController().signal` to the core helper (no
cross-process abort plumbing yet). The only ceilings are the bridge's
60s `SESSION_RECAP_TIMEOUT_MS` backstop and the transport-closed race
against ACP channel death. Wiring an HTTP-side AbortController in
isolation would be cosmetic — the child-side LLM call would still run
to completion, so e2e cancel is not achievable without the cross-
process abort piece. This is acceptable for v1 because recap is short
(single-attempt side-query, `maxOutputTokens: 300`, ~15s typical).
A future request-id-based cancel ext-method can plumb full end-to-end
cancellation if/when the bandwidth cost justifies it.
## Architecture

View file

@ -1135,7 +1135,7 @@ Errors:
- `400 {code: 'invalid_client_id'}` — malformed `X-Qwen-Client-Id` header.
- `404` — session unknown.
Cancellation: client disconnect aborts the bridge-side wait, but the LLM call in the ACP child runs to completion (recap is short — single-attempt, ~15s typical). A 60s backstop timeout guards against a wedged ACP channel.
Cancellation: **none in v1**. The route does not listen for HTTP client disconnect, no `AbortSignal` is plumbed into the bridge, and the ACP child runs the side-query to completion regardless of whether the caller has disconnected. The only ceilings are the bridge's 60s backstop timeout (`SESSION_RECAP_TIMEOUT_MS`) and the transport-closed race against ACP channel death. This is acceptable because recap is short (single-attempt, `maxOutputTokens: 300`, ~15s typical); a request-id-based cancel ext-method can plumb full end-to-end cancellation in a future release if the bandwidth cost ever justifies it.
### Mutation: approval, tools, init, MCP restart

View file

@ -16,6 +16,8 @@ Run Qwen Code as a local HTTP daemon so multiple clients (IDE plugins, web UIs,
- **One daemon, one workspace** — each `qwen serve` process binds to exactly one workspace at boot (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02). Multi-workspace deployments run one daemon per workspace on separate ports (or behind an orchestrator).
- **Remote runtime control** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) — change a session's approval mode (`POST /session/:id/approval-mode`), toggle a tool per workspace (`POST /workspace/tools/:name/enable`), scaffold an empty `QWEN.md` (`POST /workspace/init`, mechanical only — does NOT call the model; for AI-fill, follow up with `POST /session/:id/prompt`), or restart a single MCP server with a budget pre-check (`POST /workspace/mcp/:server/restart`). All four are strict-gated — configure `--token` first.
- **Session recap** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) follow-up) — fetch a one-sentence "where did I leave off" summary of an active session (`POST /session/:id/recap`). Wraps core's `generateSessionRecap` as a side-query against the fast model; pollutes neither the main chat history nor the SSE stream. Non-strict gate (same posture as `/prompt`); SDK helper `client.recapSession(sessionId)`.
- **Known limit — token-cost amplification:** the route is a pure-cost endpoint (each call is an LLM side-query, no state benefit) and the daemon has no per-route rate limit in v1. On a no-token loopback default a buggy or malicious local client can spam it to burn tokens. Configure `--token` (and optionally `--require-auth`) on shared dev hosts before exposing the daemon.
- **Concurrent recap safety:** two simultaneous `/recap` calls on the same session run two independent side-queries. `generateSessionRecap` reads a snapshot of the chat history via `GeminiClient.getChat().getHistory()` and feeds it to a separate `BaseLlmClient.generateText` call (via `runSideQuery`); it never appends to or mutates the session's `GeminiChat`. Safe to call from multiple clients without coordination.
## v0.16-alpha known limits

View file

@ -1474,11 +1474,24 @@ export function createServeApp(
// #4175 follow-up. Wraps `generateSessionRecap` (core/services/
// sessionRecap.ts) so daemon clients can fetch a one-sentence
// "where did I leave off" summary without driving the agent through
// a full prompt turn. Posture mirrors `/session/:id/prompt`:
// non-strict gate (token cost, not state mutation), and disconnect
// is detected via `res.once('close')` for the bridge-side
// cancellation. Best-effort — `recap: null` on short history or
// transient model failure is a normal 200, not an error.
// a full prompt turn. Non-strict gate (token cost, not state
// mutation), matching `/session/:id/prompt`'s posture.
//
// v1 cancellation: NONE on the route side. There is intentionally no
// `res.once('close')` listener and no `AbortSignal` plumbed into
// `bridge.generateSessionRecap`. The only ceilings are the bridge's
// 60s `SESSION_RECAP_TIMEOUT_MS` backstop and the
// `getTransportClosedReject` race against ACP transport death. This
// matches the ACP child's `acpAgent.ts` handler, which also passes
// a never-aborting `AbortController().signal` to the core helper
// because there is no cross-process abort plumbing yet. Wiring an
// HTTP-side AbortController would be cosmetic — the child-side LLM
// call would still run to completion, so e2e cancel is not
// achievable in v1. Recap is short (single-attempt side-query,
// ~15s typical, `maxOutputTokens: 300`), so this is acceptable.
//
// Best-effort — `recap: null` on short history or transient model
// failure is a normal 200, not an error.
const sessionId = req.params['id'];
if (!sessionId) {
res