Skip to content

feat(daemon): Add SSE stream and client observability - #8572

Merged
doudouOUC merged 2 commits into
QwenLM:mainfrom
doudouOUC:agent/sse-stream-client-observability
Aug 6, 2026
Merged

feat(daemon): Add SSE stream and client observability#8572
doudouOUC merged 2 commits into
QwenLM:mainfrom
doudouOUC:agent/sse-stream-client-observability

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds first-stage observability for REST SSE connections. Each accepted stream receives a stable UUID and emits correlated lifecycle telemetry and daemon logs for opening, slow-client warnings, eviction, state resync, and closing. Close records include duration, settled frame count, the last written event ID, backpressure and live-lag statistics, terminal event attribution, and an explicit close reason.

The TypeScript SDK carries the existing client identity plus optional connection reason and adjacent accepted-stream lineage while remaining compatible with older daemons and response headers stripped by gateways. The WebUI supplies a reason only when it can distinguish prompt restart, normal stream end, transport error, or state resync. Subscriber diagnostics contain queue and trigger metadata but no event payload, and session identity remains owned by the SSE route.

Why it's needed

A session ID identifies a logical session but cannot distinguish the physical SSE connections created by reconnects. Existing slow-client warnings therefore cannot be reliably tied to a particular stream, predecessor, queue state, write-backpressure episode, replay gap, or close outcome. This change makes a session-level query sufficient to reconstruct the REST SSE timeline and distinguish client restart storms, slow network or proxy reads, large frames, many small queued frames, and replay resynchronization without changing reconnect, replay, queue, backpressure, or eviction behavior.

Reviewer Test Plan

How to verify

  1. Run cd packages/acp-bridge && npx vitest run src/eventBus.test.ts, cd packages/core && npx vitest run src/telemetry/daemon-tracing.test.ts, cd packages/sdk-typescript && npx vitest run test/unit/AcpHttpTransport.test.ts test/unit/DaemonSessionClient.test.ts test/unit/RestSseTransport.test.ts, cd packages/webui && npx vitest run src/daemon/session/DaemonSessionProvider.test.tsx, and cd packages/cli && npx vitest run src/serve/auth.test.ts src/serve/server.test.ts. The diagnostics, telemetry context, REST handshake and lineage, WebUI reason transitions, and daemon lifecycle tests should pass.
  2. Open a REST SSE subscription with a client ID, connection reason, and predecessor. Confirm the successful response exposes a UUID stream ID and that opened and closed logs and spans share the session, client, and stream identities.
  3. Exercise slow frame and byte queues, writer idle timeout, socket error, replay resync, terminal events, and client disconnect. Confirm each diagnostic belongs to one stream, contains no payload or authentication data, and reports the expected close reason and settled-write statistics.
  4. Repeat with omitted or invalid optional diagnostic fields and with an older-daemon-style response that lacks the stream header. Confirm the connection is still accepted and subsequent reconnects remain compatible without inventing lineage.

Targeted validation passed locally: 58 EventBus tests, 13 daemon telemetry tests, 169 SDK tests, 181 WebUI tests, and the 40 focused daemon SSE tests after rebasing onto the current main branch. Relevant package builds, typechecks, lint, formatting, and diff checks also passed. The repository-wide CLI build/typecheck remains blocked by pre-existing Ink selection typing errors unrelated to this change.

Evidence (Before & After)

N/A — protocol diagnostics and observability only; no TUI change.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS, Node.js v22.22.3, npm 10.9.8, npm workspaces.

Risk & Scope

  • Main risk or tradeoff: The REST SSE hot path now performs bounded per-frame timing and emits additional low-frequency lifecycle diagnostics. The write loop also exits immediately when a write settles as closed and skips the terminal stream_error attempt after a socket error; these are defensive cleanup changes, while reconnect, replay, queue, backpressure, and eviction semantics remain unchanged.
  • Not validated / out of scope: DataWorks gateway forwarding, CORS, buffering, and indexing require deployment-side validation; Windows and Linux were not tested locally; ACP HTTP and WebSocket streams, active-stream status, metrics labels, and automatic supersession are out of scope. The repository-wide CLI build/typecheck is still blocked by unrelated pre-existing Ink selection typing errors.
  • Breaking changes / migration notes: None. All request and SDK fields are optional, missing response headers are supported, and diagnostics do not drive authentication or stream behavior.

Linked Issues

Closes #8571

中文说明

本 PR 做了什么

为 REST SSE 连接增加一期可观测性。每条成功接受的流都会获得稳定的 UUID,并针对打开、慢客户端告警、淘汰、状态重同步和关闭发出可关联的生命周期遥测与 Daemon 日志。关闭记录包含持续时间、已完成写入的帧数、最后写入的事件 ID、背压与实时延迟统计、终态事件归因以及明确的关闭原因。

TypeScript SDK 会携带现有客户端身份,以及可选的连接原因和相邻成功流的 lineage,同时兼容旧版 Daemon 和被网关剥离响应头的情况。WebUI 只在能够区分 Prompt 主动重启、正常流结束、传输错误或状态重同步时提供原因。订阅者诊断包含队列与触发事件元数据,但不包含事件 payload;会话身份仍由 SSE 路由负责。

为什么需要它

session ID 能标识逻辑会话,却无法区分重连产生的多条物理 SSE 连接。因此,现有慢客户端告警无法可靠关联到具体 stream、前驱流、队列状态、写背压、Replay Gap 或关闭结果。此变更使仅凭会话维度查询即可重建 REST SSE 时间线,并区分客户端重启风暴、网络或代理读取缓慢、大帧、大量小帧积压和 Replay 重同步,同时不改变重连、Replay、队列、背压或淘汰行为。

Reviewer 测试计划

如何验证

  1. 运行 cd packages/acp-bridge && npx vitest run src/eventBus.test.tscd packages/core && npx vitest run src/telemetry/daemon-tracing.test.tscd packages/sdk-typescript && npx vitest run test/unit/AcpHttpTransport.test.ts test/unit/DaemonSessionClient.test.ts test/unit/RestSseTransport.test.tscd packages/webui && npx vitest run src/daemon/session/DaemonSessionProvider.test.tsx 以及 cd packages/cli && npx vitest run src/serve/auth.test.ts src/serve/server.test.ts。诊断、遥测上下文、REST 握手与 lineage、WebUI 原因转换和 Daemon 生命周期测试应全部通过。
  2. 使用 client ID、连接原因和前驱流打开 REST SSE 订阅。确认成功响应暴露 UUID stream ID,并且 opened 与 closed 日志和 Span 共享相同的 session、client 和 stream 身份。
  3. 覆盖慢帧队列、慢字节队列、writer idle timeout、socket error、Replay Resync、终态事件和客户端断开。确认每条诊断唯一归属于一条流,不包含 payload 或认证数据,并报告预期关闭原因和已结算写入统计。
  4. 省略可选诊断字段、传入非法诊断字段,并模拟缺少 stream 响应头的旧 Daemon。确认连接仍会被接受,后续重连保持兼容且不会伪造 lineage。

本地定向验证已通过:58 个 EventBus 测试、13 个 Daemon 遥测测试、169 个 SDK 测试、181 个 WebUI 测试,以及 rebase 到当前 main 后的 40 个定向 Daemon SSE 测试。相关 package 的 build、typecheck、lint、格式检查和 diff 检查也已通过。全仓 CLI build/typecheck 仍被与本变更无关的既有 Ink selection 类型错误阻塞。

证据(变更前后)

N/A——仅涉及协议诊断与可观测性,没有 TUI 变化。

已测试平台

操作系统 状态
🍏 macOS
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

环境(可选)

macOS、Node.js v22.22.3、npm 10.9.8、npm workspaces。

风险与范围

  • 主要风险或权衡:REST SSE 热路径现在会执行有界的逐帧计时,并产生额外的低频生命周期诊断。写入已关闭时循环会立即退出,socket error 后也会跳过注定失败的终态 stream_error 写入;这些属于防御性清理改动,重连、Replay、队列、背压和淘汰语义保持不变。
  • 未验证或范围外:DataWorks 网关透传、CORS、缓冲和索引需要在部署侧验证;本地未测试 Windows 和 Linux;ACP HTTP 与 WebSocket 流、活跃流状态、指标标签和自动 supersession 不在本期范围。全仓 CLI build/typecheck 仍被无关的既有 Ink selection 类型错误阻塞。
  • 破坏性变更或迁移说明:无。所有请求字段与 SDK 字段均为可选,支持响应头缺失,诊断字段不参与鉴权或流行为。

关联 Issue

Closes #8571

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 7520ae6. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

ℹ️ No screenshot changed against the PR base — but this PR edits 1 render-shaping file:

  • packages/webui/src/daemon/session/DaemonSessionProvider.tsx

Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to packages/web-shell/client/e2e/visuals/screenshots.spec.ts that seeds whatever state the UI is gated on; it then appears here as a head-only (NEW) capture.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 7520ae6, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Read every changed file at 9dc33831d and ran the affected suites locally. This is careful, well-scoped work — a design doc, a protocol-doc update, a clean onSubscriberDiagnostic seam that keeps EventBus free of session/client/stream identity, and 16 new focused tests. No correctness defect found in the SSE hot path. Findings below are one dead field, one attribute-naming deviation, one design gap, and nits.

Local verification

Suite Result
acp-bridge/src/eventBus.test.ts 58/58 pass
cli/src/serve/auth.test.ts 29/29 pass
cli/src/serve/server.test.ts 859/861 pass — the 2 failures are a 50k-item /workspace/:id/sessions timeout and an ENOTEMPTY tmpdir race in POST /workspace/reload, both unrelated to SSE. All 10 new SSE tests pass.
sdk-typescript (RestSseTransport + DaemonSessionClient + AcpHttpTransport) 150/150 pass
webui/DaemonSessionProvider.test.tsx 174 pass / 20 fail — the identical 20 also fail at the base commit 67d128715 (transcript pagination / notices routing), so pre-existing environmental, not this PR. The new sseConnectReason assertions all pass.
prettier --check on all changed source files clean

Things done right (worth keeping)

  • res.prependOnceListener('finish'|'close', finalize) — the telemetry middleware registers its own res.once('finish'|'close', finish) before next() (serve/server/telemetry.ts:776), so prepending is what guarantees the close attributes land before the request span ends. Non-obvious and correct.
  • liveTimingEnabled = lastEventId === undefined is genuinely right: EventBus.subscribe gates the whole replay block on lastEventId !== undefined, and only that path force-pushes replay_complete. A fresh stream cannot accidentally measure replayed frames as live lag.
  • subscriberDiagnosticHandled's try/catch preserves publish()'s never-throws contract, and capturing the telemetry context at handler entry means a warning emitted from a publisher's stack still parents to the right long-lived span.
  • Access-Control-Expose-Headers updated for the new header — easy to forget, and the WebUI would silently get undefined lineage without it.

Also checked

Rate limiting is unaffected by the newly-sent X-Qwen-Client-Id on the SSE subscription — GET /session/*/events is explicitly exempt in rate-limit.ts resolveTier, so the header does not newly re-bucket anything through createKeyExtractor.

Two things that belong in Risk & Scope

The body says stream control behavior is unchanged, but two changes go slightly beyond observability (both are improvements — they just deserve a line): the terminal stream_error frame is now skipped on a socket-error close, and the write loop now breaks when a write settles as closed. Inline comments on both.

Also worth one line: for REST SSE subscribers the EventBus stderr fallback is now suppressed, so qwen serve: EventBus subscriber evicted {...} becomes qwen serve: SSE client evicted {...} and EventBus slow_client_warning becomes SSE slow client warning. Strictly richer, but anything grepping daemon stderr for the old strings breaks.

Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/cli/src/serve/routes/sse-events.ts Outdated
Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx
Comment thread packages/cli/src/serve/routes/sse-events.ts
Comment thread packages/cli/src/serve/routes/sse-events.ts
Comment thread packages/cli/src/serve/routes/sse-events.ts Outdated
Comment thread packages/cli/src/serve/routes/sse-events.ts Outdated
Comment thread packages/sdk-typescript/src/daemon/RestSseTransport.ts
Comment thread packages/cli/src/serve/routes/sse-events.ts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC
doudouOUC force-pushed the agent/sse-stream-client-observability branch from 9dc3383 to a7dee45 Compare August 5, 2026 18:00
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 5, 2026
@doudouOUC
doudouOUC marked this pull request as ready for review August 6, 2026 11:44
@doudouOUC
doudouOUC enabled auto-merge August 6, 2026 11:44
@doudouOUC doudouOUC self-assigned this Aug 6, 2026
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review feedback addressed in 7520ae66d7

Feedback Resolution
Dead restartReason field Fixed: removed the field and assign prompt_restart directly on both restart paths.
Bare close OTel attributes Fixed: all close event and request-span attributes now use the qwen-code.daemon.sse.* namespace, with regression coverage for the former bare keys.
State Resync lineage Not taking: the approved design intentionally limits lineage to adjacent accepted REST streams on the same DaemonSessionClient and permits a State Resync client rebuild to break that best-effort chain.
Socket/write control-flow disclosure Addressed: Risk & Scope now documents immediate exit after a closed write and skipping the doomed terminal stream_error write after socket_error.
Diagnostic sanitization and bounding Fixed: reason and session identity use the same bounded single-line sanitizer, and code-point materialization is bounded before allocation.
UUID validator and malformed client ID rationale Fixed: added reciprocal validator cross-references and documented why a malformed diagnostic client ID does not reject the SSE handshake.
Lifecycle handled boundary No change: the telemetry helper is async and its rejection is already caught, so it cannot synchronously escape after the human-visible log is written.

Validation completed:

  • Focused CLI SSE route tests passed, including sanitization and namespaced close attributes.
  • Full WebUI provider tests passed (194 tests).
  • SDK RestSseTransport tests passed (47 tests).
  • WebUI and SDK typechecks passed; formatting, ESLint, and diff checks passed.
  • Two consecutive open-ended diff audits and an independent read-only verification found no additional actionable issues.
  • The repository build/typecheck gate remains blocked by pre-existing CLI Ink selection typing errors outside this PR's diff; the build reached the CLI package before failing on those known files.

@wenshao

wenshao commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Overview

This adds per-connection identity and lifecycle observability to the REST SSE surface, without touching stream behavior:

  • Daemon route (sse-events.ts) — each accepted stream gets a randomUUID() stream id returned as X-Qwen-SSE-Stream-Id, plus opened / slow_client_warning / client_evicted / state_resync_required / closed records carrying session, client, stream, predecessor, and connect-reason. The close record adds duration, settled frame count, last written id, backpressure/drain/live-lag stats, terminal-event attribution, and an explicit close_reason.
  • EventBus — new onSubscriberDiagnostic callback on SubscribeOptions; the bus stays unaware of session/client/stream identity and falls back to its existing stderr line when the callback returns false or throws.
  • SDKclientId / sseConnectReason / previousSseStreamId / onSseStreamAccepted on the transport options; DaemonSessionClient owns identity and adjacent lineage; ACP HTTP/WS transports explicitly ignore them.
  • WebUI — supplies only the four reasons it can prove (prompt_restart, stream_end, transport_error, state_resync).
  • CORS now exposes the new header; protocol doc + a design doc are included.

What's done well

  • Layering. EventBus gains a callback but no identity; the route closes over session/client/stream in its own closure. That's the right seam, and subscriberDiagnosticHandled() keeps the publish() never-throws contract intact while still preserving the legacy stderr fallback — both branches are tested (returns false / throws).
  • Telemetry context capture. captureDaemonTelemetryContext() at route entry + runWithDaemonTelemetryContext() at emit time is exactly the fix this needs: a slow-client warning fires synchronously inside EventBus.publish(), i.e. under the publisher's async context, and would otherwise be parented to a model call instead of the long-lived SSE request span. Verified runWithDaemonTelemetryContext runs its callback synchronously up to the first await, so addDaemonRequestAttribute in finalize() still lands on a live span.
  • prependOnceListener('finish' | 'close', finalize) to run ahead of the request-span-ending middleware listener (which registers earlier, so a plain on() would lose the race). The finalized / sseCounted latches keep the close record single-shot and activeSseCount exact — both asserted in tests.
  • Namespaced qwen-code.daemon.sse.duration_ms to avoid the bare duration_ms the Log-to-Span bridge reinterprets, with an explicit test that no bare key is ever set. Easy to get wrong; nice catch.
  • Input handling. Allowlisted connectReason, UUID-validated previousStreamId, CLIENT_ID_RE-validated client id, and boundedDiagnosticString() stripping C0/C1, LS/PS, and bidi overrides before anything reaches a log line. The test drives %0Aforged, , and a 10k-char trigger type — log injection into structured daemon logs is properly closed.
  • Compatibility. All params optional, header optional, and the old-daemon case (handshake accepted with no stream-id header → next connect is resume with no predecessor) is pinned by test. Also verified the SSE route has no strict unknown-query rejection, so old daemons genuinely ignore the new params.
  • Test coverage is unusually thorough: ~530 lines of new daemon SSE tests covering correlation, rejected subscriptions, invalid diagnostics, warning/eviction context, replay gating of live lag, terminal-event attribution, socket error vs iterator error precedence, and writer-idle drain measurement, plus SDK lineage/ACP-switch/auto-reconnect-fallback cases and WebUI reason transitions.

I also verified the two places this could have silently no-op'd: VirtualSubagentTarget.iterate passes opts straight to bus.subscribe(), so the callback does reach EventBus on the subagent path; and replay_complete is force-pushed unconditionally at the end of every lastEventId !== undefined replay (including the epoch_reset / ring_evicted branches), so liveTimingEnabled reliably flips on resumed streams. snapshot: true only appends one synthetic frame after replay, so it doesn't skew live-lag on fresh streams either.

Suggestions

1. connectReason conflates "absent" with "invalid". parseSseConnectReason() returns 'unknown' for both a missing param and a garbage one, and the attribute is emitted unconditionally. The design doc stages the rollout as daemon → gateway → SDK/WebUI, but with this normalization you can't answer "how many streams are still from pre-attribution clients?" from telemetry — the exact question the staged rollout creates. Consider omitting client_reported_connect_reason when the param is absent, or keeping unknown strictly for "present but unparseable".

2. terminalEventType = 'client_evicted' on the writer-idle path (sse-events.ts) attributes a daemon-synthesized frame as a terminal bus event. An operator filtering terminal_event_type=client_evicted now matches both real EventBus queue evictions and writer-idle timeouts; only close_reason disambiguates. Since close_reason: writer_idle_timeout already carries the information, consider leaving terminalEventType unset there.

3. droppedAfter: event.id as number (eventBus.ts) is safe today — publish() stamps id before fanout — but the cast sits directly beneath a comment explaining that the synthetic frame has no id, which reads as self-contradictory. A one-line note ("event is the published event, always stamped; the frame below is the one without an id") would save the next reader the trip.

4. SSE_STREAM_ID_RE is duplicated in sse-events.ts and RestSseTransport.ts with only "keep in sync" comments, and the two sides have different jobs — the daemon validates a client-supplied predecessor while generating v4 itself, the SDK validates a daemon-supplied header. If the daemon ever moves to UUIDv7 both need editing. At minimum, a test asserting randomUUID() output satisfies the SDK-side regex would pin the contract.

5. Truncation is invisible. boundedDiagnosticString() cuts a 10k-char triggerEventType to 128 code points with no marker, so the log claims a type that was never published. An suffix (or a sibling truncated: true) keeps it honest. The double slice itself is a good DoS guard — only the marker is missing.

6. session.id uses the raw path segment while every daemon log in the same handler uses diagnosticSessionId. This matches the pre-existing convention in error-response.ts, so it isn't a new hole, but within one file the inconsistency reads as an oversight; a short comment ("OTel attributes are structured, not line-formatted") would settle it.

Risks / behavior changes

The PR's Risk section is accurate. For reviewers, the non-observability changes are:

  • The write loop now breaks immediately on a closed write outcome instead of looping once more and checking res.writableEnded — one fewer event pulled from the bus per dying stream. Strictly better; no wire change.
  • stream_error is skipped when closeReason === 'socket_error' — that write would have failed anyway and is already .catch()-swallowed.
  • res.flushHeaders() is now wrapped: a throw aborts, finalizes, ends, and returns rather than escaping the handler, and activeSseCount stays balanced on that path.
  • activeSseCount now decrements on finish as well as close (idempotent via sseCounted).

One inherent caveat worth knowing: close_reason is best-effort. finalize is prepended to res's close, so if close ever precedes error on an abrupt reset the stream is recorded as client_disconnect rather than socket_error. Node emits error first in practice and the synchronous-throw path is covered by test, but the field shouldn't be treated as authoritative for socket-failure rates.

Performance: the per-frame hot path adds one boolean read, one _meta lookup, and one Date.now() on live frames; performance.now() is called only on backpressure entry/exit. Lifecycle logs are O(1) per stream and emitDaemonLog short-circuits when the telemetry SDK isn't initialized. No concerns.

Security: no payloads, tokens, or auth data reach diagnostics; every client-supplied field is allowlisted or regex-validated before it reaches a log line or span attribute; diagnostics never feed auth, replay, eviction, dedup, or supersession. The newly exposed CORS header is a server-generated UUID.

Overall this is well-scoped and carefully built — the suggestions above are all minor and none of them block.

中文小结

结论:整体质量很高,建议均为小改动,不阻塞合入。

做得好的地方:EventBus 只拿回调、不感知身份,分层干净;captureDaemonTelemetryContext + runWithDaemonTelemetryContext 解决了"慢客户端告警在 publisher 异步上下文里触发、会被挂到错误 span"这个最容易踩的坑;prependOnceListener 抢在 request span 结束监听器之前执行;duration_ms 做了完整命名空间以避开 Log-to-Span 桥的特殊字段,并有测试兜底;所有客户端可控字段都经过白名单/正则/控制字符清洗后才进日志;测试覆盖非常扎实。另外我核对了两处可能静默失效的点——虚拟 subagent 路径确实把 opts 透传给了 bus.subscribereplay_complete 在所有 resume 分支都会无条件下发,因此 live lag 的门控是可靠的。

主要建议:

  1. connectReason 把"未传"和"非法"都归一成 unknown,导致灰度期无法从遥测区分旧客户端;建议未传时干脆不发该属性。
  2. writer idle 路径把 terminalEventType 设为 client_evicted,会和真正的 EventBus 淘汰混在一起,建议不设(close_reason 已足够)。
  3. event.id as number 的强转紧挨着"合成帧没有 id"的注释,容易误读,加一行说明即可。
  4. SSE_STREAM_ID_RE 在 daemon 和 SDK 两处重复,建议至少加一个断言 randomUUID() 满足 SDK 侧正则的测试。
  5. boundedDiagnosticString 截断无标记,建议补 truncated 字段。
  6. session.id 用了未清洗的原始路径段(与仓库既有约定一致),建议加注释说明。

另外提醒 reviewer:本 PR 除可观测性外还有几处行为变更(写入 closed 时立即退出循环、socket error 后跳过 stream_errorflushHeaders 包了 try/catch、activeSseCount 也在 finish 时递减),PR 描述里都已如实列出。close_reason 属于尽力而为的归因,不建议当作 socket 失败率的权威指标。

@wenshao

wenshao commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 61 passed · 0 failed · 61 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:61 通过 · 0 失败 · 61 总计

Verification report

PR 8572 Deep Verification — feat(daemon): Add SSE stream and client observability

Verdict: merge-ready — 61 scripted assertions executed, 61 pass / 0 fail.
Verified head: 7520ae66d7f90ed8f7443494be0c962d5e14a951 (git rev-parse HEAD^2), merged over base tip 35108c0cf (HEAD^1).
Effective diff: git diff HEAD^1..HEAD — 23 files, +1974/−159.

中文摘要

结论:merge-ready(61 条脚本化断言全部通过,0 失败)。

  • A/B 结论:中心主张成立。对 head 与 base(HEAD^1)各自重新编译受影响 workspace 后,用真实 registerSseEventsRoutes + 真实 EventBus + 回环 HTTP 客户端跑了 3 组同构场景(生命周期、队列溢出驱逐、非法诊断字段),共 32 条断言:head 侧每次接受的 SSE 流都返回合法 UUID 的 X-Qwen-SSE-Stream-Id 响应头,opened/closed 生命周期日志与响应头共享同一 streamId,close 记录含 closeReasoneventFramesWriteSettledlastEventIdWritten、慢客户端计数等统计;驱逐场景中诊断按流归因(closeReason=event_bus_evictedeventBusEvictionReason=queue_overflow)。base 侧对照全部按预期为"无该字段/无该头",且帧投递、驱逐、计数器行为逐格保留(含 base 旧版 stderr 驱逐日志作为场景有效性对照)。
  • 向后兼容:真实 RestSseTransport(head SDK dist)对 head daemon、无响应头的"旧 daemon"、以及伪造非法头三种服务端各跑一遍:连接均正常,缺头/坏头时 onSseStreamAccepted(undefined),不伪造 lineage;DaemonSessionClient lineage 状态机 4 格验证(initial→resume+前驱、显式 reason 优先、调用方伪造的身份字段被运行期剥离、无头接受后不虚构前驱)。
  • 测试非空:对 4 个中心新测试做变异矩阵——未变异对照 4/4 绿;删除 stream-id 响应头、硬编码 close reason、跳过 connectReason 枚举校验三个变异分别被对应测试以"期望值 vs 实际值"断言杀死,无存活变异。
  • 门禁:acp-bridge eventBus 58/58、core daemon-tracing 13/13、sdk-typescript 3 个改动测试文件 150/150、webui DaemonSessionProvider 194/194、cli auth+server 907/907 全绿;全 workspace typecheck 0 错误(typecheck/eslint 门禁均用植入错误做过活性验证);改动文件 eslint/prettier 干净。
  • Findings:无阻塞项。仅两处说明:PR 描述中"全仓 CLI build/typecheck 仍被既有 Ink selection 类型错误阻塞"在本次验证的 head 上已不成立(见 Corrections);base 会把未校验的 client id 原样写入日志而 head 做了归一化(属加固,非回归)。
  • 未覆盖:writer-idle/socket-error/replay-resync/terminal 等关闭原因的实线 A/B 格(由 907 条套件测试钉住)、ACP 传输忽略 SSE 字段的实线验证(仅注释改动)、虚拟子代理会话路径仅代码走读、OTel 真实导出(harness 无 SDK,为 no-op)、逐 commit 归因(depth-2 浅克隆)、Windows 平台、DataWorks 网关透传、逐帧计时开销基准。

Central claim and A/B

Central claim: each accepted REST SSE stream gets a stable UUID (X-Qwen-SSE-Stream-Id) and correlated lifecycle records — opened/closed logs sharing session, client, and stream identity, with close records carrying duration, settled-frame count, last written event id, backpressure/live-lag stats, and an explicit close reason.

Secondary claims: (1) the SDK carries client identity, connect reason, and predecessor lineage while tolerating older daemons and stripped/mangled headers without inventing lineage; (2) reconnect/replay/queue/backpressure/eviction behavior is unchanged.

The A/B drove the real route module from each tree's dist/ (head: CI build at HEAD; base: rebuilt core, acp-bridge, cli in a tmp/base-tree worktree at HEAD^1 over a hardlinked copy of the root node_modules — the workspace symlinks are relative, so every @qwen-code/* import automatically re-pointed into the base tree; readlink -f confirmed all four resolved to tmp/base-tree/packages/*) around a real EventBus, with a recording daemonLog shim and /__test/* control endpoints; the client was a raw fetch SSE reader. Witness: evidence/01-ab-head-vs-base-sse-cells.png, raw cells in ab-results.json, per-cell server logs in server-<arm>-<scenario>.std*.log.

Scenario / cell Oracle head base (control)
lifecycle: connect with client id + connectReason=resume + predecessor, 3 events, client abort X-Qwen-SSE-Stream-Id valid UUID 23088da0-ebb3-… ✅ header absent (as predicted)
lifecycle opened record: streamId == header, clientId, reason, predecessor, sessionId ✅ all correlated ✅ record exists, streamId undefined (control)
lifecycle closed record: closeReason, settled frames, last id client_disconnect, settled=3, lastId=3, same streamId ✅ record exists, fields undefined, durationMs only (control)
lifecycle / eviction / invalid getActiveSseCount() back to 0 after close (finalize refactor) ✅ 0 in all cells ✅ 0 in all cells
eviction: ?maxQueued=16, reader stalls, 200-event burst client_evicted frame with reason=queue_overflow reaches client (behavior preserved) ✅ (A/A)
eviction route diagnostics attributed to stream SSE client evicted + SSE slow client warning records carry header streamId, queue metadata, trigger type/bytes ✅ no such records; legacy EventBus subscriber evicted stderr line fired instead (scenario-validity control)
eviction closed record attribution closeReason=event_bus_evicted, eventBusEvictionReason=queue_overflow, slowWarningCount=1, terminalEventType=client_evicted ✅ closed record present, no attribution fields (control)
invalid fields: bogus reason, non-UUID predecessor, invalid client id handshake still 200, fields degrade connectReason=unknown, clientId/predecessor dropped, stream still UUID-addressed ✅ 200 (control; base logged the raw invalid client id — see Findings)

32/32 assertions pass (sse-ab-driver.mjs; expected base absences are encoded as control assertions that must pass). Close-record arithmetic cross-checked: eviction cell wrote 20 settled frames = ids 1–18 + synthetic slow_client_warning + client_evicted frames (synthetics burn no id: slot), consistent with lastEventIdWritten=18 and droppedAfter=19 (id 19 was the rejected trigger event).

The first eviction attempt returned 400 on BOTH arms — the harness asked maxQueued=4 and the pre-existing validator enforces [16, 2048]. Identical on base and head ⇒ harness bug, not a PR effect; corrected to 16 and re-run (the captured run is the corrected one).

Back-compat and lineage (SDK, head build)

Real RestSseTransport from the built SDK dist plus a recording fake client around the real DaemonSessionClient lineage machine (sdk-backcompat.mjs, witness evidence/02-sdk-backcompat-and-lineage.png, raw sdk-results.json): 10/10.

Cell Result
T1: SDK vs head daemon — header/query carry clientId, reason, predecessor; daemon parses them; onSseStreamAccepted receives the lowercased header UUID ✅ wire confirmed both directions
T2: SDK vs headerless fake "old daemon" — stream still delivers, callback gets undefined
T3: SDK vs mangled header (not-a-uuid) — callback gets undefined
L1: first stream — explicit prompt_restart honored, session-owned clientId used, caller-supplied spoofed identity fields (untyped JS) stripped at runtime, no predecessor
L2: second stream — auto resume + predecessor = previously accepted id
L3: acceptance callback attempted on every subscribe
L4: after a headerless acceptance, the next stream omits previousSseStreamId — no invented lineage

Diagnostic-context sanitizer boundary (hostile-session-probe.mjs, 5/5): a session id of 200 chars + U+202E/U+202D bidi overrides + \n + BEL is still accepted with 200 + stream id; the diagnostic value is truncated to 128 code points with all control/bidi characters stripped and nothing reaching stderr raw.

Reviewer Test Plan, walked step by step

  1. Run the listed suites — done, all green (see Targeted gates). One count note: the changed WebUI file alone runs 194 tests at this head; the "181 WebUI tests" in the description is a whole-package count from the author's rebase point, not a mismatch.
  2. Open a subscription with client ID, reason, predecessor; confirm UUID + correlated identities — done live (lifecycle cells above; header UUID matches opened/closed streamId and the SDK-side accepted id).
  3. Exercise slow queues, writer idle, socket error, replay resync, terminal events, disconnect; confirm per-stream attribution, no payload/auth in diagnostics, expected close reasons/stats — slow-queue, eviction, and disconnect done live; diagnostics contain only queue metadata + triggerEventType/triggerEventBytes (no event payload, no auth data — inspected the actual JSON in ab-results.json). Writer idle / socket error / replay resync / iterator-error close reasons are pinned by the PR's own new tests inside the 907-test gate run, not by live cells (budget choice; listed in Not covered).
  4. Omitted/invalid fields + older-daemon response without the stream header — done: invalid-fields cell (both arms) plus SDK cells T2/T3 (onSseStreamAccepted(undefined), reconnect stays compatible, no invented lineage).

No step was unreachable.

Mutation matrix (vacuity check on the central new tests)

Filtered to the four central tests in packages/cli/src/serve/server.test.ts; each run in a scratch worktree at HEAD (run-mutation-matrix.sh, witness evidence/03-mutation-matrix-central-tests.png, raw mutation-matrix-raw.log):

build result killed by
control (unmutated head) 4/4 pass
M1: delete res.setHeader(SSE_STREAM_ID_HEADER, streamId) 2 fail correlates the SSE response… + adds stream identity to EventBus warning and eviction diagnostics (expected undefined to be '<uuid>')
M2: hardcode resolvedCloseReason = 'client_disconnect' 2 fail correlates… + records a socket error without letting iterator cleanup overwrite it
M3: parseSseConnectReason skips enum validation 1 fail normalizes invalid client-reported diagnostics without rejecting or logging them

Every mutation failed the intended behavioral assertion (expected-vs-actual, not import/compile breakage); no survivors in the central set, and the green control is the positive control for the harness. Worktree restored (git status --porcelain empty).

Targeted gates

Gate Result
packages/acp-bridgevitest run src/eventBus.test.ts 58/58 pass
packages/corevitest run src/telemetry/daemon-tracing.test.ts 13/13 pass
packages/sdk-typescriptAcpHttpTransport + DaemonSessionClient + RestSseTransport unit tests 150/150 pass
packages/webuiDaemonSessionProvider.test.tsx 194/194 pass
packages/cliauth.test.ts + full server.test.ts 907/907 pass
All-workspace npm run typecheck (9 workspaces) exit 0, zero error TS (typecheck-all-workspaces.log)
eslint on every changed file (two batches) exit 0, no output
prettier on every changed source file all clean

Gate liveness proven before citation: a planted const x: number = "string" in a scratch packages/cli/src file was reported as TS2322 by tsc --noEmit (then removed, clean again), and a planted unused-variable/filename violation made eslint exit 1. The mutation matrix above is the liveness proof for the test gates.

Corrections

  • Description claim, "The repository-wide CLI build/typecheck remains blocked by pre-existing Ink selection typing errors unrelated to this change" (stated twice, EN + 中文) does not hold at the verified head. npm run typecheck across all 9 workspaces exits 0 with zero error TS (log: typecheck-all-workspaces.log), and tsc --noEmit in packages/cli is clean on its own. The gate was proven live first: a planted const x: number = "string" in a scratch file was reported as TS2322, then removed. The statement was likely true on the author's pre-rebase base; at 7520ae66d the block no longer exists. This is a correction to the description, not a request to change code.

Findings

No blocking findings.

  • (Info) Base logged unvalidated client ids; head sanitizes them. In the invalid-fields cell the base opened record contains clientId: "!!!invalid!!!" verbatim, while head drops it (fails CLIENT_ID_RE) and bounds/sanitizes session ids in diagnostics (verified separately with a hostile id: 200 chars + bidi overrides U+202E/U+202D + newline + BEL → accepted with 200, diagnostic value truncated to 128 code points, all control/bidi chars stripped, nothing reaching stderr raw — hostile-session-probe.mjs, 5/5). This is a hardening improvement introduced by the PR, recorded so the behavioral delta is intentional and visible.
  • (Info) Docs were updated consistently. docs/developers/qwen-serve-protocol.md and the design doc match the implemented header/param names, enum values, normalization semantics, and the payload-free diagnostic contract; no drift found.

Not covered

  • Per-commit attribution: the checkout is depth 2; git rev-list HEAD^1..HEAD^2 yields only 7520ae66d while the metadata lists two commits — a7dee45a is unreachable, so the aggregate diff was verified, not each commit separately.
  • Live A/B cells for the remaining close reasons (writer_idle_timeout, socket_error, iterator_error, session_terminal, source_complete) and the state_resync_required telemetry path — pinned by the PR's new tests within the 907-test cli gate, not re-driven live (budget). The heartbeat interacts with the idle timer, making a clean live cell costly.
  • Virtual subagent session path: onSubscriberDiagnostic is type-threaded and forwarded to the bus via iterate() → this.bus.subscribe(opts) (code read), but not exercised live — it needs a populated history-replay runtime.
  • ACP transports (AcpHttpTransport/AcpWsTransport): change is comments-only (REST-only policy documented); not live-driven.
  • Real OTel export: emitDaemonLog/addDaemonRequestAttribute are no-ops without a configured SDK in the harness; the telemetry side is pinned by core unit tests (13/13) and the mocked-span assertions in server.test.ts. The span-attribute timing question (request span may already be ended when closed fires at stream close) was considered and not asserted either way — no evidence produced.
  • Performance: bounded per-frame performance.now() timing was not benchmarked; claimed "bounded low-frequency" cost taken as code-read.
  • Windows (author tested macOS only) and DataWorks gateway header forwarding (deployment-side).
  • The snapshot=1 subscription path and the 429 subscriber-limit path were not A/B'd (pre-existing behavior, untouched hunks).

Methodology

Ran in the CI verify container (node:22-bookworm, Node v22.23.2) on the merge-ref checkout (180604b07). Base control: git worktree add tmp/base-tree HEAD^1 + hardlinked node_modules copies (root and per-workspace nested dirs; package.json/lockfile untouched by the PR, so no dependency confound); rebuilt core, acp-bridge, cli there and asserted readlink -f node_modules/@qwen-code/* points into the base tree before trusting any control cell. Harness sse-server.mjs boots the tree's own dist/ route + real EventBus behind a minimal registry shim with a recording daemonLog; sse-ab-driver.mjs drives scenarios over loopback and evaluates per-arm assertions (expected base absences encoded as passing controls). SDK probes use the built dist/daemon bundles against the head harness, a headerless fake server, and a mangled-header fake server, plus a recording fake client for the DaemonSessionClient lineage machine. Mutation matrix via point mutations of sse-events.ts in a scratch worktree with vitest -t filtering. Gates: the five suites named in the PR's test plan, full-workspace typecheck, targeted eslint/prettier — each liveness-proven where cited (planted TS error, planted lint violation). Raw logs and harness scripts live alongside this report; the base/mutant worktrees were removed after capture.

Evidence images

01-ab-head-vs-base-sse-cells

02-sdk-backcompat-and-lineage

03-mutation-matrix-central-tests

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — the design doc and the staged rollout plan make this a straightforward gate to run.

Template looks good ✓

Problem: real and well-stated. The linked issue (#8571, self-reported by the author) describes an attribution hole in diagnostics this repo already emits: a session ID identifies the logical session but not the physical SSE connection behind it, so slow-client warnings, evictions, replay gaps, and backpressure episodes can't be joined into one timeline. That's an observed operational gap, not a theoretical concern — no before/after reproduction is expected for an observability feature, and none is claimed.

Direction: aligned. Daemon/SSE reliability is an actively worked surface in recent releases (no direct CHANGELOG mention of stream observability, but adjacent daemon/Web Shell work lands steadily — #7896, #8335 and friends). One note for the maintainer: the PR consumes the telemetry surface and extends the SDK↔daemon wire contract (a new response header, two new query params, new exported SDK types). Everything is optional and diagnostic-only by design, but flagging it per the sensitive-area rule.

Size: cross-package change (acp-bridge, cli, sdk-typescript, webui; core itself is test-only). 859 production-logic lines (605 of them in the SSE route), 1,116 test lines, 158 docs lines. It's a feat, so no size block — but 500+ production lines touching core paths gets flagged for maintainer awareness per the core-module rule.

Approach: the scope feels right for a "first stage". The design doc lists explicit non-goals (metric labels, active-stream status, ACP streams, automatic supersession), all wire fields are optional, and EventBus stays identity-free via a callback seam — the right layering. No materially simpler path jumps out: cutting the close-record statistics would remove exactly the data needed to tell a reconnect storm from a slow read or a large frame, which is the point of the change. Heads-up for reviewers: the diff carries four disclosed defensive behavior changes in the write path (immediate break on a closed write, skipping the doomed stream_error after a socket error, a flushHeaders try/catch, counter decrement on finish too) — all declared in Risk & Scope, but this isn't purely additive observability.

Risk: no match against the revert-history high-risk path panel.

Moving on to code review. 🔍

中文说明

感谢贡献——设计文档和分阶段上线计划让这个门禁审查很顺畅。

模板完整 ✓

问题:真实且描述清晰。关联 issue(#8571,作者自报)指出的是本仓库已有诊断的一个归因缺口:session ID 只能标识逻辑会话,无法标识其背后的物理 SSE 连接,因此慢客户端告警、淘汰、replay 缺口和背压事件无法被拼接成一条时间线。这是已观测到的运维缺口,不是理论性顾虑——可观测性类 feature 不要求也不声称提供 before/after 复现。

方向:对齐。Daemon/SSE 可靠性是近期持续活跃的方向(CHANGELOG 没有直接提及 stream 可观测性,但相邻的 daemon/Web Shell 工作持续落地——#7896#8335 等)。提醒 maintainer:此 PR 使用了 telemetry 面并扩展了 SDK↔daemon 的线上契约(一个新的响应头、两个新的查询参数、新的 SDK 导出类型)。按设计全部为可选且仅用于诊断,但按敏感领域规则予以标记。

规模:跨包变更(acp-bridge、cli、sdk-typescript、webui;core 本身只有测试改动)。859 行生产逻辑(其中 605 行在 SSE 路由),1,116 行测试,158 行文档。属于 feat,不触发规模阻塞——但触及核心路径且生产行数 500+,按核心模块规则标记给 maintainer 知悉。

方案:作为"第一阶段"范围合适。设计文档列出了明确的非目标(指标标签、活跃流状态、ACP 流、自动取代),所有线上字段均为可选,EventBus 通过回调接缝保持身份无感知——分层正确。没有明显更简的路径:砍掉 close 记录的统计信息,恰好会丢掉区分重连风暴、慢读取与大帧所需的数据,而那正是本变更的目的。提醒 reviewer:diff 夹带了四处已披露的写路径防御性行为变更(写入已关闭时立即退出循环、socket error 后跳过注定失败的 stream_errorflushHeaders 包 try/catch、finish 时也递减计数器)——均已在 Risk & Scope 中声明,但本 PR 并非纯增量可观测性。

风险:未命中 revert 历史高风险路径面板。

进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at 7520ae66d7f90ed8f7443494be0c962d5e14a951 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Code review

My independent proposal for this problem — daemon-minted UUID per accepted stream exposed via a response header, optional format-validated predecessor/reason query params that never feed auth or replay, lifecycle records enriched in place with the telemetry context captured at route entry (EventBus warnings fire under the publisher's async context, so they'd otherwise parent to the wrong span), an identity-free EventBus diagnostic callback, and lineage owned by the SDK session client — is essentially what this PR does. The implementation goes further than the baseline in good ways: close-record statistics (settled frames, backpressure counts, drain waits, live publish-to-write lag), and a bounded sanitizer for every client-controlled string. I didn't find a simpler path it missed.

What I verified reading the diff at 7520ae66:

  • No new core surface. captureDaemonTelemetryContext / runWithDaemonTelemetryContext / emitDaemonLog / addDaemonRequestAttribute and _meta.serverTimestamp all pre-exist; the core production diff is zero (core changes are test-only).
  • Counter balance. activeSseCount stays balanced on the new flushHeaders-throw path: the increment happens before the try, and finalize() decrements under the sseCounted latch. prependOnceListener('finish'|'close') plus the finalized latch makes the close record single-shot and run ahead of the request-span-ending listener.
  • Input hygiene. connectReason is allowlisted, previousStreamId is UUID-regex-validated and lowercased, client id reuses the existing CLIENT_ID_RE, and trigger types / resync reasons / details pass through boundedDiagnosticString (C0/C1, LS/PS, and bidi overrides/isolates stripped) before reaching daemonLog or stderr. Nothing client-controlled reaches a log line raw except session.id as a structured OTel attribute — consistent with the pre-existing convention, and noted in the maintainer's review.
  • Telemetry namespacing. All close attributes live under qwen-code.daemon.sse.*, so the bare duration_ms the Log-to-Span bridge reinterprets is never set — pinned by a core test.
  • Reuse. CLIENT_ID_RE / MAX_CLIENT_ID_LENGTH come from request-helpers, not a re-implementation. The one duplication is SSE_STREAM_ID_RE on both sides of the wire with keep-in-sync comments (suggestion 4 below).
  • The writeChain rewrite (.catch → two-argument .then) is semantically identical.

No critical blockers and no convention violations from my side. The six suggestions in @wenshao's review were posted after this commit, and his approval explicitly treats them as non-blocking. The author has since recorded a disposition for each one (no further scope expansion, per the repo's review-round churn guard): none are taken in this PR — the connect-reason absent/invalid conflation (1) and the writer-idle terminalEventType (2) are recorded as follow-up candidates if rollout query needs demand them, and the rest are declined or deferred for schema/style reasons that read as legitimate. I agree none of the six are merge blockers.

sequenceDiagram
    participant P1 as WebUI provider
    participant P2 as DaemonSessionClient
    participant P3 as RestSseTransport
    participant P4 as SSE route
    participant P5 as EventBus
    P1->>P2: events() with provable reason
    P2->>P3: subscribe(reason, predecessor)
    P3->>P4: GET events (connectReason, previousStreamId)
    P4->>P4: validate inputs, mint stream UUID
    P4-->>P3: 200 + X-Qwen-SSE-Stream-Id
    P3->>P2: onSseStreamAccepted(streamId)
    P5-->>P4: slow-client / eviction diagnostic callback
    P4->>P4: opened, warning, closed records under captured context
Loading
Files changed (23 of 23 shown)
File What changed
docs/design/2026-08-05-sse-stream-client-observability.md Design doc: wire contract, telemetry events, rollout staging, non-goals
docs/developers/qwen-serve-protocol.md Protocol doc: new query params, stream-id header, CORS expose
packages/acp-bridge/src/eventBus.test.ts Callback routing, stderr fallback on false/throw, byte accounting
packages/acp-bridge/src/eventBus.ts Optional onSubscriberDiagnostic on SubscribeOptions; queue metrics plus trigger type/size, never the payload
packages/cli/src/serve/auth.test.ts CORS expose-header assertion updated
packages/cli/src/serve/auth.ts Expose X-Qwen-SSE-Stream-Id through CORS
packages/cli/src/serve/routes/sse-events.ts The core: stream UUID, input validation and sanitization, lifecycle records, close statistics, captured telemetry context
packages/cli/src/serve/server.test.ts ~530 new lines: correlation, invalid diagnostics, replay gating of live lag, socket vs iterator error precedence, writer-idle drain
packages/cli/src/serve/virtual-subagent-sessions.ts Type plumbing so the callback reaches EventBus on the subagent path
packages/core/src/telemetry/daemon-tracing.test.ts Deferred telemetry runs under the captured request context
packages/sdk-typescript/src/daemon/AcpHttpTransport.ts Documents that SSE lifecycle fields are intentionally ignored here
packages/sdk-typescript/src/daemon/AcpWsTransport.ts Same for the WS transport
packages/sdk-typescript/src/daemon/DaemonClient.ts Plumbs the new subscribe options to the transport; exports the reason type
packages/sdk-typescript/src/daemon/DaemonSessionClient.ts Owns identity and lineage: accepted-stream state, reason inference, runtime sanitization of omitted fields
packages/sdk-typescript/src/daemon/DaemonTransport.ts DaemonSseConnectReason type and transport subscribe options
packages/sdk-typescript/src/daemon/RestSseTransport.ts Sends client-id header and diagnostic query params; validates the response stream-id header
packages/sdk-typescript/src/daemon/index.ts Re-exports DaemonSseConnectReason
packages/sdk-typescript/src/index.ts Package-level export of DaemonSseConnectReason
packages/sdk-typescript/test/unit/AcpHttpTransport.test.ts ACP transport ignores the REST SSE fields
packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts Lineage state machine: adjacent streams, ACP switch clears lineage, reason forwarding, reconnect fallback
packages/sdk-typescript/test/unit/RestSseTransport.test.ts Wire: diagnostics sent, valid header accepted, invalid header ignored
packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx Reason transition tests
packages/webui/src/daemon/session/DaemonSessionProvider.tsx Sets the four provable connect reasons on reconnect paths

Testing — the PR's own CI (this is a CI run; no PR code was executed here)

All PR-event CI runs are now green on the reviewed commit, including Serve A/B, which was still in flight at the previous pass. The macOS/Windows test jobs and the CLI integration job are skipped by workflow configuration (the normal PR-CI shape in this repo — not failures). The Test (ubuntu-latest, Node 22.x) job is the one carrying the ~530 new SSE route assertions plus the EventBus, SDK, and WebUI suites.

Check Conclusion
Qwen Code CI — Test (ubuntu-latest, Node 22.x) ✅ success
Qwen Code CI — Test (macos-latest / windows-latest, Node 22.x) skipped (workflow config)
Qwen Code CI — Integration Tests (CLI, No Sandbox) skipped (workflow config)
SDK Java ✅ success
Web-shell Visuals ✅ success
Real daemon E2E (Java 11) ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Serve A/B ✅ success

Sandboxed verification of the one claim static review and unit tests can't fully settle — that the four disclosed write-path changes (immediate break on a closed write, skipping the doomed stream_error after a socket error, the flushHeaders guard, counter-on-finish) preserve reconnect/replay semantics, and that close-reason attribution holds under real sockets rather than simulated ones — has now partially landed: the Serve A/B PR-CI job (base vs head against a live daemon) is green on this commit. The /verify run triggered in this thread is still in flight; when its report lands, it deserves the same skepticism as the fork's own CI logs: the sandbox bounds what the code can do, not what the report says. Local testing was macOS-only per the PR description; nothing here was independently re-run.

中文说明

代码审查:我对这个问题的独立方案(daemon 侧为每条接受的流生成 UUID 并通过响应头暴露、可选且格式校验的前驱/原因查询参数且不参与鉴权与 replay、在路由入口捕获遥测上下文后就地丰富生命周期记录、EventBus 通过身份无感知的诊断回调解耦、lineage 由 SDK 会话客户端持有)与本 PR 基本一致;实现还更进一步(close 统计、背压/排空/实时滞后测量、对所有客户端可控字符串的有界清洗)。没有发现被遗漏的更简路径。已核验:未新增 core 面(core 生产代码零改动);flushHeaders 抛出路径下 activeSseCount 保持平衡;客户端可控输入全部白名单/正则/清洗后才进日志;close 属性全部命名空间化,不会触发 Log-to-Span 桥对裸 duration_ms 的特殊解释;复用既有 CLIENT_ID_RE 等,无平行工具。未发现阻塞项或规范违规。@wenshao 的六条建议提交于本 commit 之后,其 approve 已明确视为不阻塞;作者随后逐条记录了处置(按仓库 review 轮次 churn 守则不再扩大本 PR 范围):均不纳入本 PR——第 1 条(connectReason 缺失/非法混淆)与第 2 条(writer-idle 的 terminalEventType)记为后续跟进候选,其余以 schema/风格上的合理理由拒绝或推迟。我认同六条均不构成合入阻塞。

测试:本次为 CI 运行,未执行任何 PR 代码。被审 commit 上所有 PR-event CI 运行现已全绿,包括上一轮审查时仍在运行的 Serve A/B;macOS/Windows 测试与 CLI 集成任务按工作流配置跳过(本仓库 PR CI 的常规形态,非失败)。关于"四处已披露的写路径变更在真实 socket 下保持 reconnect/replay 语义、close 归因成立"这一静态审查与单测无法完全定论的声明:Serve A/B(base 与 head 对真实 daemon 的对比)已在本 commit 上通过;本线程触发的 /verify 沙箱运行仍在进行——报告落地后应以审视 fork CI 日志的同样怀疑态度阅读。本地测试仅 macOS(作者自述),此处未独立复跑任何结果。

Qwen Code · qwen3.8-max

Reviewed at 7520ae66d7f90ed8f7443494be0c962d5e14a951 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review, but the score is capped by policy, not by doubt: a cross-package feat touching core paths at 859 production lines was escalated for maintainer awareness at Stage 0, and the change touches the telemetry surface and the SDK↔daemon wire contract — both areas where this gate defers to a human instead of approving.

The commit is unchanged since the previous pass — this re-run re-reviewed 7520ae66, and the diff re-check found nothing new: input hygiene, counter balance, namespaced telemetry attributes, and old-daemon compatibility all hold as described in Stage 2. What moved around it:

  • CI settled green. Every PR-event run on this commit has completed — Qwen Code CI (Linux unit suite with the ~530 new SSE route assertions), Serve A/B (still in flight at the previous pass), SDK Java, Web-shell Visuals, Real daemon E2E, Desktop Shell — all success. The macOS/Windows and CLI-integration jobs remain skipped by workflow configuration, the normal PR-CI shape here.
  • @wenshao's approval stands on this exact commit.
  • The author recorded a disposition for all six of wenshao's non-blocking suggestions — none taken in this PR (review-round churn guard), with the connect-reason absent/invalid conflation and the writer-idle terminalEventType kept as follow-up candidates. The reasoning reads as legitimate; none of the six block merge.

One honest correction to the previous pass: it said a maintainer word on the size escalation plus green sandbox lanes "would move this to a bot approval". That overstates what this gate can do — per the core-module rule, a 500+-line core-touching feat is never bot-approved; the merge decision belongs to a human. On the evidence side, the four disclosed write-path changes are no longer wholly unsubstantiated: Serve A/B (base vs head against a live daemon) landed green on this commit, and the /verify run is still in flight — when its report lands it deserves the same skepticism as the fork's own CI logs.

⏸️ Deferring to @wenshao — no blockers found, so no request-changes either; the PR simply sits in the bucket this gate does not approve (core-module size escalation + telemetry/wire-contract surface). Main requires two approvals and one (yours) stands on this commit; the second is a maintainer's call. If you want the /verify report in hand before merging, that lane is still running in this thread — otherwise this looks ready as-is.

中文说明

置信度:3/5 —— 审查本身干净,但分数由政策封顶而非疑虑:跨包 feat 触及核心路径且生产行数达 859 行,已在 Stage 0 升级为 maintainer 知悉;且变更触及 telemetry 面与 SDK↔daemon 线上契约——这两类领域本门禁一律交由人工决定而非自动批准。

commit 与上一轮相同——本次重跑复审了 7520ae66,diff 复核未发现新问题:输入卫生、计数器配平、命名空间化的遥测属性、旧 daemon 兼容性均如 Stage 2 所述成立。周边状态有变化:

  • CI 全部落地且为绿。 此 commit 上所有 PR-event 运行均已完成——Qwen Code CI(含约 530 行新增 SSE 路由断言的 Linux 单测套件)、Serve A/B(上一轮时仍在运行)、SDK JavaWeb-shell VisualsReal daemon E2EDesktop Shell——全部成功。macOS/Windows 与 CLI 集成任务仍按工作流配置跳过,属本仓库 PR CI 常规形态。
  • @wenshao 的 approval 在此 commit 上仍然有效。
  • 作者已对 wenshao 的六条非阻塞建议逐条记录处置——按 review 轮次 churn 守则均不纳入本 PR,其中 connectReason 缺失/非法合一与 writer-idle 的 terminalEventType 记为后续跟进候选。理由成立,六条均不阻塞合入。

对上一轮评论的一处诚实更正:上轮称"maintainer 确认规模升级、沙箱通道转绿后,本 PR 可转为机器人批准"——这高估了本门禁的权限:按核心模块规则,500+ 行触及核心的 feat 从不被机器人批准,合入决定属于人工。证据方面,四处已披露的写路径变更不再完全缺乏实证:Serve A/B(base 与 head 对真实 daemon 的对比)已在本 commit 上通过;/verify 运行仍在进行——报告落地后应以审视 fork CI 日志的同样怀疑态度阅读。

⏸️ 转交 @wenshao —— 未发现阻塞项,因此也不发起 request-changes;本 PR 只是落在本门禁不予批准的区间(核心模块规模升级 + telemetry/线上契约面)。main 需要两个 approve,目前一个(你的)在此 commit 上有效;第二个 approve 由 maintainer 决定。若希望拿到 /verify 报告再合入,该通道仍在本线程运行中——否则以当前状态看已可合入。

Qwen Code · qwen3.8-max

Reviewed at 7520ae66d7f90ed8f7443494be0c962d5e14a951 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 7520ae66d7f90ed8f7443494be0c962d5e14a951 — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 7520ae66d7f90ed8f7443494be0c962d5e14a951既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Thanks @wenshao — I rechecked all six suggestions against 7520ae66. Given the repository's review-round churn guard and that these are explicitly non-blocking, I am not expanding this PR further; recording the disposition here so none are silently dropped.

Suggestion Disposition
Distinguish absent vs invalid connectReason Not taking in this PR. The approved contract intentionally normalizes missing/invalid client-reported reasons to unknown, and the staged rollout explicitly expects old clients to appear with reason unknown. Separating rollout adoption from malformed input would be a new telemetry/query contract for a follow-up.
Omit writer-idle terminalEventType Not taking in this PR. terminal_event_type records the terminal SSE frame emitted (client_evicted), while event_bus_eviction_reason and close_reason=writer_idle_timeout distinguish EventBus eviction from daemon synthesis. Changing that meaning now would create a different query contract.
Clarify droppedAfter: event.id with another comment Deferred as a readability-only suggestion. The surrounding comment describes the synthetic eviction frame; the published event is stamped before fanout. There is no correctness ambiguity at runtime, and repository style defaults to no extra comments.
Add a cross-package UUID-regex test No change. Both validators already accept UUID versions 1–8, including v7; the daemon route test asserts its generated randomUUID() is v4 and SDK tests cover accepted/rejected response headers. An additional test would not remove the duplicated contract.
Mark bounded diagnostic truncation Deferred. The field is intentionally a bounded, sanitized diagnostic prefix. Adding an ellipsis or a sibling flag changes the indexed telemetry schema/query semantics and is not required for correctness or safety.
Comment on raw OTel session.id No change. The raw structured OTel attribute preserves exact session correlation; only human-visible line-formatted logs use the bounded form. This matches the existing telemetry convention and does not need another explanatory comment.

No code change or new commit was made. Suggestions 1 and 2 remain reasonable follow-up candidates if rollout-adoption or terminal-frame query requirements call for different semantics.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 7520ae66d7f90ed8f7443494be0c962d5e14a951 — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 7520ae66d7f90ed8f7443494be0c962d5e14a951既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, no blockers. Disciplined observability: diagnostics carry only event type + serialized bytes (never payloads); client inputs strictly validated (connectReason enum, previousStreamId UUID regex, X-Qwen-Client-Id charset — no CRLF/log injection, invalid normalizes); no new metric maps (cardinality safe); connection accounting leak-free (increment after accepted subscription, idempotent finalize decrements once, 400/404/429 never touch counter); pure instrumentation (behavior deltas benign). P2 (one-line, take before merge): sse-events.ts:180 puts raw sessionId into telemetryBaseAttributes/span attrs, skipping boundedDiagnosticString — use the already-computed diagnosticSessionId. P3s: first-observer-wins close reason, cosmetic terminal_event_type mislabel on dead socket, stderr format change (note in changelog), surrogate-pair slice, minor test gaps.

@doudouOUC
doudouOUC added this pull request to the merge queue Aug 6, 2026
Merged via the queue into QwenLM:main with commit 037b4d9 Aug 6, 2026
258 of 268 checks passed
@doudouOUC
doudouOUC deleted the agent/sse-stream-client-observability branch August 6, 2026 16:58
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.7.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(daemon): Add SSE stream and client observability

4 participants