* feat(serve): add pollable turn-status endpoints for daemon sessions Add GET /session/:id/turns/current and GET /session/:id/turns/:promptId so external callers can poll a turn's lifecycle state (queued / running / completed / cancelled / error) and result instead of holding the SSE stream for the whole turn lifetime. - Live state comes from the bridge's pending prompt queue; settled outcomes from persisted turn_result transcript records, so results survive daemon restarts and the daemon keeps no per-turn memory - Each prompt captures its own recording and settles exactly that one, so overlapping turns (DAEMON-003 deadline overlap) can never misattribute one turn's outcome to another promptId - Enforces the same client authorization as POST /session/:id/prompt Refs #8680 * test(serve): update telemetry route count * fix(serve): prefer settled turn outcome over deadline error overlay When the prompt-deadline path latches an error terminal in the overlay and the child later settles and persists a non-error turn_result for the same promptId, the poll surface previously kept the overlay error while enriching it with the successful resultText, and flipped to completed only after overlay eviction or restart. Merge via mergeTerminalWithPersisted at the two enrich call sites so the persisted outcome supersedes a bridge-synthesized error terminal once it exists; the exactly-once turn_error event publication and FIFO release are unchanged. The different-promptId endedAt tie-break is intentionally untouched. * fix(serve): pin turn-start session identity for turn-result settle R4-1: settle resolved the ChatRecordingService at settle time, so a startNewSession rotation mid-turn could land the turn_result record in the new session's transcript while the poll surface kept reading the old one. Capture the recorder at turn start and settle on that instance; pin the outgoing service's session identity at rotation so the late append keeps the pre-rotation sessionId. R4-2: reject empty error.message/error.code in turn_result payloads, mirroring the existing empty-promptId rejection. Adds rotation/pin regression tests plus the round-4 test suggestions (extractor fallback, startedAt, cancel/error race matrix, resultCode defaulting, removed-prompt projections). * fix(serve): enforce the turn_result bounded contract on the write path R3-3: cap promptId, stopReason, and originatorClientId at 256 chars in isTurnResultRecordPayload, closing the unbounded echo of corrupted-transcript values through GET /session/:id/turns/:promptId; recordTurnResult now validates payloads against the same contract before appending, so type-correct but invalid shapes (error state without error, error on non-error states) can no longer produce records invisible to the restart scan. Also lands the four round-5 test assertions: merged-payload error-leak pin, multi-model-call settle count, successor attribution in the superseded-throws test, and the early session-mismatch guard pin. * fix(serve): address round-6 review findings on daemon turn status - Session: settle a successor-aborted turn as cancelled only when the thrown error is the abort itself; genuine failures after a NEW_PROMPT abort surface as error, matching the send-loop contract - bridge: serve repeat polls of a settled promptId from the enriched overlay instead of re-scanning the child transcript, and give the turn-status read the transcript timeout instead of the 10s init default - bridge: forward the channel display text unchanged; Session treats an empty display text as absent for the turn record ([image] fallback) - Session: cap streamed-response accumulation for turns without a channel delivery at the turn-result bound - docs: document the bounded non-monotonicity of poll terminals * fix(serve): guard turn-status reads against rewind races and keep the trusted prompt projection A successful rewind that completes while a getSessionTurnStatus child transcript scan is in flight could let the pre-rewind record be cached into the freshly cleared overlay and served forever. Track a per-session rewind generation captured before the scan and discard the scanned outcome when it moved. enrichTerminalTurnStatus and the deadline-supersede merge returned the child-recorded promptText ahead of the bridge's trusted display projection, leaking hidden channel context on the poll surface. Make promptText/promptTextTruncated backfill-only and keep the terminal's projection in the supersede path. Make the pinning test adversarial and correct a false comment about the child's ''-as-absent fallback. --------- Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: qqqys <qys177@gmail.com>
25 KiB
Serve Runtime
Overview
packages/cli/src/serve/ is the boot layer for qwen serve. It translates CLI flags into ServeOptions, validates startup configuration, builds the Express app, wires middleware, registers routes, exposes daemon-host preflight/status providers, maintains the permission audit ring, and owns the two-phase graceful shutdown sequence. HTTP-facing work lives in this layer; ACP-facing work lives one layer below in @qwen-code/acp-bridge (see 03-acp-bridge.md).
Responsibilities
- Parse and validate
ServeOptions: listen address, auth, workspace, session / connection caps, MCP budget / pool, CORS, prompt / SSE / session idle timeouts, rate limit, and related toggles. - Canonicalize the primary workspace exactly once, and canonicalize every repeated
--workspacebefore registering session runtimes. The primary canonical form is shared by/capabilities.workspaceCwd, thePOST /sessionfallback, and the primary bridge. - Reject unsafe or invalid startup configurations: non-loopback bind without token,
--require-authwithout token,--allow-origin '*'without token,mcpBudgetMode='enforce'without a positivemcpClientBudget, a nonexistent or non-directory--workspace, and invalid timeout or rate-limit values. - Construct the
WorkspaceFileSystemfactory, permission audit publisher,DaemonStatusProvider, andacp-bridge. - Build the Express app, wire middleware (
allowOriginCorsover the mutable origin allowlist ->hostAllowlist-> access log ->bearerAuth-> rate limit -> JSON parser -> telemetry -> per-routemutationGate), and mount session, workspace CRUD, file, device-flow auth, permission vote, and ACP HTTP routes. (The unconditionaldenyBrowserOriginCorswall remains only in the bootstrap app,run-qwen-serve.ts.) - Bind the listening port and register signal handlers.
- Run two-phase shutdown on SIGINT/SIGTERM; force-exit on a second signal.
Architecture
Entry: runQwenServe(opts, deps) in packages/cli/src/serve/run-qwen-serve.ts. Returns a RunHandle ({ url, port, close, ... }).
App factory: createServeApp(opts, getPort, deps) in packages/cli/src/serve/server.ts. Builds the Express Application. Direct embedders and tests call it without the bootstrap wrapper.
Capability registry: SERVE_CAPABILITY_REGISTRY in packages/cli/src/serve/capabilities.ts. Each tag has a since version and optional modes. Conditional tags are omitted when their deployment or runtime predicate is false; the registry and predicate map are the source of truth. See 11-capabilities-versioning.md.
Middleware (packages/cli/src/serve/auth.ts and server.ts):
| Middleware, in registration order | Purpose | Notes |
|---|---|---|
allowOriginCors |
Always installed on the runtime app over a MutableOriginAllowlist: --allow-origin <pattern> entries seed it, Local Control adds the LAN origin while enabled; unmatched origins get the 403 deny envelope. |
See 12-auth-security.md. |
hostAllowlist(bind, getPort) |
On loopback, validate Host belongs to localhost, 127.0.0.1, [::1], or host.docker.internal plus the actual port. |
Defense against DNS rebinding. Comparison is case-insensitive and cached per port. The Local Control LAN listener always enforces its advertised-authority Host check, whatever the primary bind is. |
| Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to DaemonLogger when a request finishes. |
Registered before bearerAuth, so 401 denials are logged too. Skips /health and heartbeat. |
bearerAuth(token) |
SHA-256 plus timingSafeEqual constant-time bearer comparison. |
Open passthrough when no token is configured (loopback dev default). Bearer scheme is case-insensitive. |
| Rate-limit middleware | Optional per-tier token bucket for prompt, mutation, and read routes. | Registered after bearerAuth and before JSON parsing; returns 429 before parsing when a bucket is exhausted. |
express.json({ limit: '10mb' }) |
JSON body parsing. | Parse errors return 400. |
daemonTelemetryMiddleware |
Wraps classified daemon API requests that reach this point in an OpenTelemetry span through withDaemonRequestSpan. |
Attributes include canonical route, resolved workspace hash, sessionId, clientId, and status code. Earlier auth, rate-limit, and body-parser rejections are outside this span boundary. |
createMutationGate (per-route) |
Route-level opt-in gate for mutation routes that require token even on loopback. | Returns 401 { code: 'token_required' }. Not global app.use; routes call mutate({ strict: true }) as needed. |
Subsystems:
| Path | Role |
|---|---|
serve/fs/ |
WorkspaceFileSystem factory plus policy.ts (size/trust/binary checks), paths.ts (canonicalize, resolveWithin, symlink rejection), audit.ts, and typed FsError values. |
serve/routes/workspace-file-read.ts, workspace-file-write.ts |
HTTP handlers for GET /file, GET /file/bytes, POST /file/write, and POST /file/edit. |
serve/workspace-memory.ts |
GET/POST /workspace/memory (QWEN.md CRUD). |
serve/workspace-agents.ts |
GET/POST/DELETE /workspace/agents (subagent CRUD). |
serve/daemon-status-provider.ts |
Env snapshot plus daemon-host preflight cells: Node version, CLI entry, workspace stat, ripgrep, git, npm. |
serve/permission-audit.ts |
PermissionAuditRing (512-entry FIFO) and createPermissionAuditPublisher. |
serve/auth/device-flow.ts, qwen-device-flow-provider.ts |
Device-flow OAuth routes. See 12-auth-security.md. |
serve/daemon-logger.ts |
DaemonLogger structured file logs. See 19-observability.md. |
serve/debug-mode.ts |
Shared isServeDebugMode() predicate controlling verbose error context in HTTP responses. |
serve/acp-http/ |
ACP Streamable HTTP transport (RFD #721), mounted at /acp. Seven files implement JSON-RPC POST, SSE GET, DELETE teardown, and shared bridge usage in parallel with the REST surface. |
serve/web-shell-static.ts, serve/web-shell-resolver.ts |
Locate and mount the built Web Shell assets (the daemon's browser UI) at /, /assets, and /session/:id, plus the SPA deep-link fallback registered after all API routes. Mounted before bearerAuth in every launch mode — a browser cannot attach Authorization to a navigation or subresource — while every API route it calls stays token-gated. Degrades to API-only when the assets are absent; --no-web opts out. |
ACP bridge package imports:
- Event-bus primitives are imported from
@qwen-code/acp-bridge/eventBus. - Status primitives are imported from
@qwen-code/acp-bridge/status. serve/acp-session-bridge.tsremains as the CLI-local compatibility facade for the broader bridge surface.
Flow
Boot sequence
- Resolve and trim token from
opts.tokenorQWEN_SERVER_TOKEN; this avoids a trailing newline fromcat token.txtsilently breaking bearer comparison. - Hostname typo guard:
--hostname localhost:4170errors and suggests--port. - Auth preflight: non-loopback without token refuses;
--require-authwithout token refuses. - Workspace validation: absolute path, exists, directory.
EACCES/EPERMare wrapped to point at the flag. - Canonicalize workspace:
canonicalizeWorkspace(rawWorkspace)runsrealpathSync.nativeonce and feeds/capabilities, thePOST /sessionfallback, and the bridge. - MCP budget validation: positive integer;
enforcerequires a budget. - MCP pool toggle inference: parent env
QWEN_SERVE_NO_MCP_POOL=1makesmcpPoolActive=false, so capabilities honestly omitmcp_workspace_poolandmcp_pool_restart. - CORS / timeout / rate-limit validation:
--allow-origin '*'requires token; prompt, writer, channel idle, session idle, reaper, and rate-limit window values fail fast when invalid. - Per-handle
childEnvOverrides: passQWEN_SERVE_MCP_CLIENT_BUDGETandQWEN_SERVE_MCP_BUDGET_MODEto the ACP child throughBridgeOptions.childEnvOverridesinstead of mutatingprocess.env. - Load
settings.jsononce: readcontext.fileName,policy.permissionStrategy, andpolicy.consensusQuorum. Corrupt files fall back to defaults.validatePolicyConfig()checkspolicy.*againstSERVE_CAPABILITY_REGISTRY.permission_mediation.modes; unknown strategies or non-positiveconsensusQuorumthrowInvalidPolicyConfigError. A quorum set under a non-consensusstrategy logs a stderr warning. - Allocate
PermissionAuditRing(512 entries). - Build
fsFactory:runQwenServedefaults totrusted: true; directcreateServeAppcallers default totrusted: falseand warn once. createHttpAcpBridge, see03-acp-bridge.md.createServeAppassembles Express.- Create and lifecycle-bind the HTTP(S) server before listening, then call
server.listen(port, hostname)and resolve the actualgetPort()for host allowlist. Conversations ownership cannot start until this listener and the remaining host startup gates are ready. - Register SIGINT / SIGTERM handlers for graceful shutdown through the shared app lifecycle.
Graceful shutdown
- Seal admission and begin all drains on the first signal:
- Dispose the device-flow registry and cancel pending flows.
bridge.shutdown()marks each channelisDying = true, sends graceful close to each ACP child stdin, waitsKILL_HARD_DEADLINE_MS(10s) per channel, then callschannel.kill()if needed.
- Close the listener while app and host drains run:
server.close()stops accepting new connections and lets in-flight requests finish.SHUTDOWN_FORCE_CLOSE_MS(5s) triggersserver.closeAllConnections().- A second 2s deadline escalates again if needed.
- Release Conversations ownership only after positive shutdown proof from the listener, app-local work, host-owned work, Live discovery cleanup, and runtime drains. Any incomplete proof rejects shutdown instead of allowing an unsafe handoff.
- Second signal while exiting:
bridge.killAllSync()+process.exit(1)to avoid orphaned children blocking daemon exit.
State and lifecycle
RunHandle exposes:
url: resolved listen URL, after ephemeral port resolution.port: actual port, including0resolution.close(): programmatic shutdown for embedders and tests.
Calling createServeApp directly still returns only an Application. An embedder that needs Live/Conversations must create the actual Node server, call getServeAppLifecycle(app).bindServer(server) before its first listen(), and await lifecycle.close() during shutdown. Without binding, ordinary routes remain available but Live/Conversations fail closed. Calling raw server.close() triggers event-driven cleanup, but the embedder must still await lifecycle.close() to observe drain or ownership-release failures.
Dependencies
Upstream used by serve/ |
Downstream using serve/ |
|---|---|
@qwen-code/acp-bridge: bridge, event bus, status types |
The qwen CLI serve subcommand handler |
packages/core: loadSettings, getCurrentGeminiMdFilename, Config, WorkspaceContext |
Direct embedders, tests |
ACP SDK (@agentclientprotocol/sdk): PROTOCOL_VERSION, ClientSideConnection through bridge |
|
Express + body-parser, node:crypto, node:fs, node:path |
Configuration
| Source | Key | Effect |
|---|---|---|
| Env | QWEN_SERVER_TOKEN |
Bearer token after trim. |
| Env | QWEN_SERVE_NO_MCP_POOL=1 |
Forces mcpPoolActive=false. |
| ACP child env | QWEN_SERVE_MCP_CLIENT_BUDGET / QWEN_SERVE_MCP_BUDGET_MODE |
Generated from --mcp-client-budget / --mcp-budget-mode and forwarded through childEnvOverrides. |
| Env | QWEN_SERVE_PROMPT_DEADLINE_MS / QWEN_SERVE_WRITER_IDLE_TIMEOUT_MS |
Default prompt / SSE idle timeouts. |
| Env | QWEN_SERVE_RATE_LIMIT* |
Rate-limit switch, prompt / mutation / read caps, and window default. |
| Env | QWEN_SERVE_DEBUG=1 |
Verbose stderr logs. See 19-observability.md. |
| Flags | --hostname, --port |
Listen binding. |
| Flags | --token, --require-auth, --enable-session-shell |
Bearer token, loopback auth hardening, and explicit shell execution switch. |
| Flag | --workspace |
Overrides process.cwd(); repeat to register additional isolated workspace runtimes. |
| Flags | --max-sessions, --max-pending-prompts-per-session, --max-connections, --event-ring-size |
Bridge / Express caps. |
| Flags | --mcp-client-budget=N, --mcp-budget-mode={off,warn,enforce} |
Forwarded to the ACP child. |
| Flags | --allow-origin, --allow-private-auth-base-url |
Browser CORS allowlist and localhost/private auth provider installation switch. |
| Flag | --web / --no-web |
Serve or skip the Web Shell UI at the daemon root (default serves). --no-web leaves the daemon API-only. |
| Flags | --prompt-deadline-ms, --writer-idle-timeout-ms, --channel-idle-timeout-ms, --initialize-timeout-ms |
Prompt, SSE writer, ACP child idle lifecycle, and ACP child request timeout control. |
| Flags | --session-reap-interval-ms, --session-idle-timeout-ms |
Disconnected-session reaping control. |
| Flags | --rate-limit* |
Per-tier HTTP rate limit. |
settings.json |
policy.permissionStrategy, policy.consensusQuorum |
MultiClientPermissionMediator policy and quorum. |
settings.json |
context.fileName |
getCurrentGeminiMdFilename override for the bridge. |
See 17-configuration.md for the merged reference.
Caveats and known limits
- Direct
createServeAppwithoutdeps.fsFactoryordeps.bridgedefaults totrusted: false; agent-side ACPwriteTextFilerejects asuntrusted_workspace. The warning is printed once. - The runtime app runs
allowOriginCorsover the mutable allowlist; unmatchedOriginvalues get the 403 deny envelope (the unconditionaldenyBrowserOriginCorswall survives only in the bootstrap app). The loopback Web Shell works because another middleware strips matching loopback same-origin values first — non-loopback binds require--allow-originfor the shell's XHRs. - Body-parser ordering: routes using
mutate({ strict: true })return 401 only afterexpress.json(). The worst case is--max-connections × express.json({limit: '10mb'}), up to about 2.5 GB of transient memory on a saturated loopback listener; this tradeoff is intentional. - Multiple daemons in one process must use per-handle
childEnvOverrides; mutatingprocess.envraces becausedefaultSpawnChannelFactorysnapshots env at spawn time.
References
packages/cli/src/serve/run-qwen-serve.ts(bootstrap, boot validation, graceful shutdown)packages/cli/src/serve/server.ts(createServeApp(), middleware and route assembly)packages/cli/src/serve/auth.ts(CORS, Host allowlist, bearer auth, mutation gate)packages/cli/src/serve/rate-limit.ts(per-tier HTTP rate limit)packages/cli/src/serve/capabilities.ts(capability registry and conditional advertisement)packages/cli/src/serve/types.ts(ServeOptions,CapabilitiesEnvelope)packages/cli/src/serve/daemon-status-provider.tspackages/cli/src/serve/permission-audit.ts- Issues: #3803, #4175