docs/design/java-daemon-sdk-alpha.md
This document defines the first daemon transport in the existing
com.alibaba:qwencode-sdk artifact. It is intentionally independent from the
legacy stdio implementation under com.alibaba.qwen.code.cli.
qwen serve without creating another Maven artifact.DaemonClient owns HTTP and worker resources, reads capabilities, and creates
sessions. Session creation defaults to sessionScope=thread. Blocking prompt
observation uses a configurable bounded worker pool rather than a global or
unbounded executor.
The shared timer only dispatches watchdog actions. Potentially blocking SSE
stream closure runs on a separate bounded pool sized to the prompt concurrency
limit, so one stalled close cannot delay another session's deadline or idle
watchdog. Each admitted prompt reserves bounded stream-cleanup capacity until
its final close task finishes. A prompt publishes its terminal once its prompt
slot is released, while its own stream is still closing, so that capacity
allows one draining cleanup per prompt slot; a terminal continuation can start
the next prompt even at the concurrency limit. Closes that stay stalled beyond
that headroom can still cause a later startPrompt call to fail with
DaemonClientCapacityException, but they cannot silently discard a
deadline-triggered close or grow cleanup work without bound.
DaemonSessionClient owns one daemon session and admits at most one local
prompt at a time. startPrompt returns a PromptCall immediately. Its
admission and terminal futures are independent, so a caller can distinguish
"the daemon accepted this prompt" from "the turn ended reliably".
Admission and terminal future continuations are dispatched through a separate,
client-owned executor so user continuations cannot delay SSE observation, its
local timeout, or prompt transport capacity. Exceptional completion follows
the same path. Publication capacity is bounded relative to
maximumConcurrentPrompts; continuations that remain blocked can therefore
cause a later startPrompt call to fail with DaemonClientCapacityException
instead of creating unbounded threads or queued work.
An indeterminate completion is not a session-reuse boundary. After admission becomes unknown or an admitted prompt ends indeterminately, the session client permanently rejects further prompts even if local stream cleanup succeeds. A local observation timeout is published without waiting indefinitely for stream closure; cleanup continues asynchronously and retains bounded client capacity until it finishes. Callers close or destroy the affected session.
PromptObserver receives typed callbacks and the raw event. Callbacks execute
serially on a client-owned daemon thread. An event cursor advances only after
all applicable callbacks return successfully. Callbacks must therefore return
promptly, must not wait on the same PromptCall, and must not close or destroy
the same session from a callback. Responding to a permission from its callback
is supported; the response method returns false when the daemon reports that
the request was already resolved or is no longer pending.
promptText is a convenience over startPrompt. It collects only assistant
text, enforces a UTF-8 byte limit, and returns a PromptTextResult only for a
matching turn_complete. A turn_error remains a reliable terminal but is
reported as PromptTurnException; any outcome without a reliable terminal is
reported as PromptOutcomeIndeterminateException with explicitly incomplete
partial text when available.
Fastjson2 encoding and strict Jackson Core decoding are implementation details.
Decoding rejects non-standard JSON and duplicate object keys. Public raw JSON
values use Java Map, List, scalar, and null values.
Creation-time model selection is intentionally not exposed in this alpha.
The daemon keeps a fresh session alive on the default model when
modelServiceId is rejected and reports the rejection only through an SSE
event emitted before the create response. The per-prompt subscription starts
from the later admission watermark, so it cannot prove that the requested
model was selected without adding a separate session-event lifecycle.
Before session creation, the SDK requires the daemon to advertise REST and
session_scope_override; it refuses to mutate when an older daemon could
silently ignore the requested scope. While a session remains open, the SDK
sends a new heartbeat mutation once per configured interval (one minute by
default) only when the daemon advertises client_heartbeat, and stops on
detach or destroy. Each heartbeat has the normal finite-request deadline and
is not retried; setting the interval to zero disables automatic keepalive.
Likewise, a prompt carrying deadlineMs is rejected before admission unless
the daemon advertises prompt_absolute_deadline, so a requested server-side
deadline cannot be silently ignored. The local observation timeout remains
independent and is always enforced by the SDK.
POST /session/:id/prompt.202 and validate {promptId,lastEventId,eventEpoch?}.GET /session/:id/events with Last-Event-ID set to the watermark
and X-Qwen-Event-Epoch set when the daemon supplied an epoch.turn_complete or turn_error.This per-prompt subscription covers content and terminal events emitted before
the 202 response reaches the client. It does not require an unknown-prompt
cache or a long-lived session pump.
The JDK HttpClient uses HTTP/1.1 and never follows redirects. Every request
sends JSON or event-stream Accept headers, bearer authentication when
configured, and the daemon-issued X-Qwen-Client-Id after session creation.
SSE additionally sends Accept-Encoding: identity, Cache-Control: no-cache,
and Last-Event-ID. When available, X-Qwen-Event-Epoch travels with that
cursor. The client seeds it from the prompt admission, learns it from a
validated SSE response header for compatibility, retains a known value when a
response omits the header, and fails closed if the value changes during prompt
observation.
Finite JSON and error bodies are consumed by a bounded subscriber and raced
against the request deadline through sendAsync; receiving response headers
does not end that deadline. Non-success SSE bodies are separately bounded by
the shorter of the request and prompt-observation budgets.
The SSE parser accepts LF and CRLF framing, comments, and multiple data:
lines. UTF-8 decoding is strict. Frames, event names, envelope version, numeric
IDs, and SSE/envelope ID consistency are validated. A malformed frame, an ID
gap, state_resync_required, session death, observer failure, idle timeout, or
reconnect exhaustion fails closed.
IDs at or below the committed cursor are duplicates and are not delivered.
The next numeric event must be exactly cursor + 1. Synthetic ID-less events
are accepted only for the daemon's documented control frames and do not move
the cursor; an ID-less content or terminal event fails closed. The
implementation reconnects only the SSE GET, using bounded exponential
full-jitter backoff, the SSE retry directive after a stream disconnect, and
Retry-After on retryable HTTP responses. Mutations are never retried
automatically.
If prompt transport fails after dispatch without a validated 202, or returns
HTTP 408 or 5xx, the admission future fails with
PromptAdmissionUnknownException; the SDK never reposts the prompt. Session
creation applies the same conservative classification through
SessionCreationOutcomeUnknownException. Permission, cancel, heartbeat,
detach, and delete apply the same classification because an intermediary
response does not prove that the daemon rejected the mutation. Detach uses the
more specific DetachOutcomeUnknownException. Every mutation is attempted at
most once per method invocation.
Only matching turn_complete and turn_error are terminal. Queue and
prompt_cancelled events are advisory. A local timeout stops observation but
does not automatically cancel the daemon turn. A cooperative daemon
cancellation completes as turn_complete with stopReason=cancelled, while an
agent or provider failure during cancellation can produce turn_error.
promptText() returns the complete result and surfaces the error terminal as
PromptTurnException; callers must wait for the terminal in both cases.
When cancellation, deadline, teardown, or agent settlement race, the daemon's
exactly-once latch publishes the first formal terminal and suppresses later
candidates. The SDK therefore treats the received terminal as authoritative
instead of deriving an outcome from the last control mutation it sent.
close() is locally idempotent, stops local observation, and attempts detach
at most once. A lost detach response is not retried. destroySession() is the
only API that issues DELETE /session/:id; it may be called after detach.
The whole artifact now requires Java 11. Java 8 users must remain on
0.0.3-alpha. The stdio API remains source-compatible but now runs on Java 11
and obtains logging through slf4j-api; applications choose their own SLF4J
provider because Logback is test-only.
The compatible daemon is the qwen-code build released from the same source
revision as the SDK. It contains the per-client detach ledger from #7386, the
per-epoch terminal guarantee from #7400, restart-safe event cursor epochs from
#7458, and this release's acknowledged admission cancellation plus FIFO
cancel-drain fence. The #7400 commit alone can still acknowledge cancel before
agent dispatch without stopping the admitted prompt, or let an unacknowledged
session-scoped cancel reach a queued successor.
The bundled ACP child handles the daemon's internal cancellation request through
one acknowledged admission-aware handshake. A custom standards-compliant ACP
child that does not implement that extension receives one standard
session/cancel notification instead. The
daemon does not advertise a capability that distinguishes these implementations
with the same REST/SSE feature set, so the SDK cannot negotiate this minimum at
runtime and fails closed when a formal terminal is absent.
The handshake intentionally waits for the targeted prompt call to settle before
the FIFO may dispatch its successor. Adding an acknowledgement-only timeout
would allow a late session-scoped cancel to reach that successor and would break
the ordering guarantee. Consequently, a provider, tool, or custom integration
that ignores its AbortSignal indefinitely can leave the cancel mutation
outcome unknown and the session unusable. Reclaiming a wedged shared ACP child
without terminating sibling sessions requires stronger runtime isolation and is
outside this alpha.
The alpha detects an event-epoch change during an observed prompt and fails
closed, but does not promise exactly-once execution across daemon restarts,
automatic epoch recovery, snapshot/resync, persisted cursors, or true
prompt-ID-targeted cancellation. It also does not expose creation-time model
selection until the daemon can return a definitive result or the SDK owns a
session-event lifecycle from Last-Event-ID: 0. An ambiguous create can leave
a daemon session that the caller cannot identify or detach until daemon-side
reaping. Those cases require stronger daemon contracts.
Unit tests use an in-process HTTP server to inject SSE fragmentation, slow single-line delivery, replay, duplicates, gaps, conflicting prompt IDs, opaque future event data, watermark replay, disconnects, compressed responses, stalled finite bodies, event-epoch propagation and mismatch, resync, observer failures, terminal absence, and ambiguous mutation responses. Lifecycle tests cover one-local-prompt admission, admission/close serialization, deadline terminal followed by session reuse, cancelled completion, teardown terminal ordering, bounded text, automatic heartbeat, idempotent close, detach client identity, detach-once, and explicit destroy.
CI compiles and tests on Java 11, 17, and 21 on Linux, with Java 21 smoke
coverage on macOS and Windows. Linux CI and the protected release workflow run
an E2E against a real qwen serve process with a temporary workspace and
model stub.