docs/design/2026-07-13-cold-first-session-support.md
The next implementation slice for #4748 is observability, not another startup cache or a new session protocol. It must explain one cold request across the daemon, the shared ACP channel, and the ACP child while preserving the current fast /health behavior.
The implementation reuses the existing daemon OpenTelemetry request/bridge spans and ACP _meta extension point. It adds:
session/new;session/new span with bounded stage durations for settings, Config initialization, authentication, file-system setup, Session registration, and response construction;QWEN_CODE_PROFILE_SESSION_START JSONL record so its detailed startChat stages can be joined to the trace.This slice does not add response headers, public JSON fields, capability flags, or a second profiler format. ACP readiness remains a separate P1 client/API change after the P0 breakdown is available.
The downstream 0.19.3-preview.2 sample showed a 2,534ms P50 from health success to Session success and a 1,713ms P50 for POST /session. The negative correlation between health-to-request delay and POST duration is consistent with a first request waiting for the remainder of automatic preheat, but browser timing cannot separate proxy, daemon, channel, and child work.
A local dry-run with the globally installed qwen 0.19.10 confirmed the same shape:
| Scenario | Observation |
|---|---|
| Process start → listener | 203ms |
Health followed immediately by cold POST /session | 1,033ms browser / 962ms daemon |
Already-preheated POST /session in a separate run | 222ms browser / 221ms daemon |
These are illustrative single runs, not an acceptance benchmark. They show that the current coarse route duration hides roughly 700–800ms that can be channel wait, ACP child bootstrap, or both.
sequenceDiagram
participant Client
participant Daemon as qwen serve
participant Bridge as ACP bridge
participant Child as qwen --acp
Client->>Daemon: GET /health
Daemon-->>Client: 200 listener ready
Daemon->>Bridge: preheat() asynchronously
Bridge->>Child: spawn + ACP initialize
Client->>Daemon: POST /session
Daemon->>Bridge: spawnOrAttach()
alt channel ready
Bridge->>Bridge: reuse channel
else spawn already in flight
Bridge->>Bridge: await remaining spawn
else no channel
Bridge->>Child: spawn + ACP initialize
end
Bridge->>Child: session/new
Child->>Child: settings + Config + auth + chat
Child-->>Bridge: NewSessionResponse
Bridge-->>Daemon: BridgeSession
Daemon-->>Client: 200
Existing observability already provides:
POST /session after the runtime app receives the request;channel.spawn, channel.initialize, and session.new;_meta keys, currently used for prompt dispatch;GeminiClient.startChat() stages.The missing pieces are any bootstrap-layer deferred-runtime wait before that request span, the current request's channel wait, correlation to an independently-started preheat trace, propagation on session/new, and timing before startChat inside the child.
When a non-bootstrap request arrives before the deferred runtime is mounted, the delegating bootstrap app records its wall-clock arrival time, the remaining runtime wait, and whether that request started runtime loading or joined work already started by health/fallback scheduling. The runtime telemetry middleware receives the same request object after mounting and backdates the HTTP span to that arrival time. Route duration metrics use the same boundary. This makes browser duration minus daemon request duration a meaningful proxy/network residual even on the cold deferred-runtime path.
Before doSpawn() awaits ensureChannel(), it classifies the synchronous channel state:
reused: a non-dying channel is already available;joined: inFlightChannelSpawn already exists;spawned_on_request: neither a live channel nor an in-flight spawn exists.It then wraps the await in a channel.wait bridge span. The production telemetry implementations invoke their callback synchronously, so the classification is read and ensureChannel() is invoked without yielding the JavaScript event loop.
Each new ChannelInfo receives a random UUID before channelFactory() is called. The same ID is attached only to spans for:
channel.spawn;channel.initialize;session.new once the channel is known.The ID is diagnostic trace data, not a metrics label or public identifier. Automatic preheat and the first Session can belong to separate traces; the channel ID links them without claiming that the later HTTP request caused the earlier work.
preheat() receives its own channel.preheat bridge span. A Session that joins it has a channel.wait span measuring only the remaining wait. channel.initialize and channel.wait overlap in that case and must not be summed.
Inside the existing session.new span, the bridge injects the active trace context into NewSessionRequest._meta. The existing injection helper already strips client-supplied reserved keys before adding daemon-owned values. After the child responds, a span event records the ACP Session ID for correlation with the JSONL profiler.
QwenAgent.newSession() extracts the daemon context from the request and starts a child qwen-code.daemon.session_start span under the parent bridge session.new span. If context is absent or invalid, normal OTel root-span behavior applies.
The child records fixed, non-overlapping durations using performance.now():
| Stage | Boundary |
|---|---|
settings_load | loadSettingsCached(cwd) |
config_setup | newSessionConfig(), including loadCliConfig(), config.initialize(), and the normal first startChat() |
auth | ensureAuthenticated() |
file_system_setup | setupFileSystem() |
session_register | createAndStoreSession(), normally constructing and registering the ACP Session; its defensive Gemini initialization is timed here only if Config did not already initialize it |
response_build | models, modes, config options, and response object construction |
The implementation E2E showed config_setup at about 200ms, with about 140ms recorded by the existing nested startChat profiler. That confirms the normal startChat() happens during config.initialize(), not during the later Session registration. The JSONL Session ID makes that nested cost joinable without file timestamp guesses. A later optimization can split Config construction from config.initialize() if representative downstream traces show the remaining unattributed Config cost is material; doing so in this slice would require threading a profiler through a method shared by new/load/resume/transcript paths.
Only fixed attribute names and bounded values are emitted:
qwen-code.daemon.channel.path = reused | joined | spawned_on_request;qwen-code.daemon.runtime.path = started_on_request | joined when the request crossed the deferred-runtime gate;qwen-code.daemon.runtime.wait_ms = finite non-negative remaining runtime wait;runtime_path = started_on_request | joined for requests that crossed the deferred-runtime gate, otherwise none;qwen-code.daemon.acp_channel.id = daemon-generated UUID;qwen-code.daemon.session_start.<stage>_ms = finite non-negative duration;qwen-code.daemon.session_start.failed_stage = one fixed stage name;session.id = ACP-generated Session ID.No workspace path, prompt, settings value, credential, model response, or file content is added.
channel.wait reflects the request's retry path; existing child cleanup and lazy retry semantics remain unchanged.channel.wait and child Session span while all can reference the same channel ID._meta is optional, so the child continues to accept ordinary NewSessionRequest messages.sessionId is additive and optional; existing fields and file layout do not change.ChannelInfo and disappears with the channel; it does not alter reuse, idle timeout, or kill logic.Returning a second timing schema in NewSessionResponse._meta would duplicate OTel, require validation/versioning, and create two sources of truth. W3C context already carries causality and the channel UUID handles the one intentionally separate preheat trace.
Server-Timing and X-Qwen-Profile-IdThese would help browser-only diagnosis, but they require proxy header pass-through and CORS exposure decisions outside this repository. The daemon request span and existing route duration already provide server time. Header work can follow if downstream tracing remains unavailable.
/health wait for ACPThis moves latency into readiness and risks health-probe regressions. /health remains listener/liveness readiness; ACP readiness is a separate future capability-gated contract.
Both change isolation and lifecycle semantics before profiling identifies a dominant stage. They are explicitly out of scope.
Focused unit tests must prove:
session/new receives daemon-owned trace metadata;channel.wait reports spawned, joined, and reused paths;The E2E dry-run compares two cases with the same workspace and auth:
POST /session;POST /session.For both, verify Session success and inspect the trace tree. The cold case must contain the request channel.wait path and the child stage attributes; the preheated case must report reused. Performance conclusions require at least 30 serialized cold starts in the representative downstream environment and are not inferred from local single-run timings.
Production changes are limited to the deferred-runtime request handoff and telemetry middleware in run-qwen-serve, the existing telemetry seam in packages/acp-bridge, ACP newSession, and the existing core session-start profiler. No Session/config/auth behavior changes.
The cross-package downstream consumers reviewed for this design are:
run-qwen-serve.ts and test/embed bridge telemetry implementations;AcpSessionBridge.spawnOrAttach() callers, which receive the same BridgeSession shape;_meta;sessionId is optional.Because this crosses core/bridge/CLI boundaries, it requires maintainer review even though the production logic change is intentionally small.