fix(sdk-java): Harden daemon transport reliability - #7603
Conversation
Pair SSE cursors with the daemon event epoch, learn validated response epochs, and fail closed when the epoch changes during prompt observation. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Thanks for the PR! Template looks good ✓ Problem: observed protocol gap. #7458 (merged) added epoch tokens to the daemon's admission response and SSE headers; #7463 (merged) added the Java daemon transport but didn't propagate epochs. After a daemon restart, the Java SDK's SSE reconnect would rely solely on numeric event IDs and could silently skip or accept events from the wrong epoch. The TypeScript SDK already implements this contract — the Java SDK is the missing piece. Direction: aligned. This completes an existing daemon contract (#7458) for the Java transport. The daemon already emits Size: not applicable — no core module paths touched ( Approach: scope feels right. Three production files (DaemonClient, DaemonSessionClient, PromptAcceptance) gain epoch threading, validation, and fail-closed mismatch detection — mirroring what the TypeScript SDK already does. Docs updated consistently. No drive-by changes. Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:已观测到的协议缺口。#7458(已合入)为 daemon 的 admission 响应和 SSE header 添加了 epoch token;#7463(已合入)添加了 Java daemon transport 但未传播 epoch。daemon 重启后,Java SDK 的 SSE 重连仅依赖数字事件 ID,可能静默跳过或接受错误 epoch 的事件。TypeScript SDK 已实现此契约——Java SDK 是缺失的一环。 方向:对齐。这是为 Java transport 补全已有的 daemon 契约(#7458)。daemon 已在 202 响应中发出 规模:不适用——未触及核心模块路径(仅 方案:范围合理。三个生产文件(DaemonClient、DaemonSessionClient、PromptAcceptance)增加 epoch 传递、校验和 fail-closed 不匹配检测——与 TypeScript SDK 已有实现一致。文档同步更新。无夹带改动。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-preview Reviewed at |
Code ReviewIndependent proposal: Given the daemon's epoch contract (#7458), the Java SDK needs to: (1) parse Comparison with the diff: The PR implements exactly this, cleanly.
No correctness bugs, no security holes, no regressions spotted. The implementation mirrors the TypeScript SDK's existing epoch handling. Tests: Six new fault-injection tests cover admission propagation, response learning, reconnect retention, epoch mismatch, malformed response epoch, and malformed admission epoch. E2E asserts the real daemon supplies a non-null epoch. CI Results (real-scenario evidence)This is a non-UI Java transport change — no TUI to exercise via tmux. The Java test suite and real-daemon E2E are the appropriate verification: The macOS failure is a pre-existing timing-sensitive test (idle watchdog under slow line delivery) — unrelated to epoch changes. It exercises the reconnect-exhaustion path under artificial timing pressure, not the epoch logic. Ubuntu and Windows pass all 113 tests; the real-daemon E2E confirms epoch propagation end-to-end. 中文说明代码审查独立方案: 基于 daemon 的 epoch 契约(#7458),Java SDK 需要:(1) 从 202 admission 解析 与 diff 对比: PR 完整且干净地实现了上述所有点。
未发现正确性 bug、安全漏洞或回归。实现与 TypeScript SDK 已有的 epoch 处理一致。 测试: 六个新故障注入测试覆盖 admission 传播、响应学习、重连保留、epoch 不匹配、非法响应 epoch、非法 admission epoch。E2E 断言真实 daemon 提供非空 epoch。 CI 结果(真实场景证据)这是非 UI 的 Java 传输层改动——无 TUI 可通过 tmux 验证。Java 测试套件和真实 daemon E2E 是合适的验证方式: macOS 失败是预存的时序敏感测试(慢行投递下的空闲 watchdog)——与 epoch 改动无关。Ubuntu 和 Windows 通过全部 113 个测试;真实 daemon E2E 确认 epoch 端到端传播。 — Qwen Code · qwen3.8-max-preview Reviewed at |
|
Confidence: 5/5 — clean, focused completion of an existing daemon contract; would merge without hesitation. This is exactly what the Java SDK needed after #7458 landed. The epoch threading is minimal (three production files, ~65 lines of logic), the validation grammar matches the daemon's wire format character-for-character, and the fail-closed ordering is correct — mismatch detection fires before a single event is consumed. The six fault-injection tests cover every path I'd want verified: propagation, learning, retention, mismatch, and both malformed-input surfaces. The real-daemon E2E confirms the daemon actually supplies an epoch end-to-end. The macOS CI flake ( No reservations. Ships the feature cleanly, tests are thorough, docs are consistent. 中文说明置信度:5/5 —— 干净、聚焦地补全已有 daemon 契约;毫不犹豫可合入。 这正是 #7458 合入后 Java SDK 所需要的。epoch 传递极为精简(三个生产文件,约 65 行逻辑),校验语法与 daemon 的 wire 格式逐字符一致,fail-closed 顺序正确——不匹配检测在消费任何事件之前触发。六个故障注入测试覆盖了我希望验证的每条路径:传播、学习、保留、不匹配,以及两个非法输入面。真实 daemon E2E 确认 daemon 端到端提供 epoch。 macOS CI 抖动( 无保留意见。功能实现干净,测试充分,文档一致。 — Qwen Code · qwen3.8-max-preview Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline. Not reviewed: build-and-test — Java/Maven not available in review environment; CI check macos-latest / Java 21 is failing.
— qwen3.7-max via Qwen Code /review
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
— qwen3.7-max via Qwen Code /review
Review —
|
| Mutation | Result |
|---|---|
delete the delete entry.cancelBroadcastWithoutPrompt; line |
allows a new idle cancellation after another prompt starts fails (2 → 1) |
replace the promptId === undefined && …WithoutPrompt === true disjunct with false |
deduplicates repeated cancellation broadcasts while idle fails |
packages/acp-bridge full file at PR head: 444 passed / 2 failed — and the identical 2 failures (forwards childEnvOverrides…, rebuilds authoritative invocation metadata…) reproduce on the origin/main baseline 73279ccfc, so they are pre-existing/environmental on this machine, not caused by this PR. Merges cleanly into current main (git merge-tree --write-tree, exit 0).
I could not run the Java suite locally (no JDK/Maven on this box); relying on CI, which is green at this head across ubuntu 11/17/21, macOS 21, Windows 21, plus the real-daemon E2E.
Findings
1. (Medium — test coverage) An existing regression guard was repurposed, losing the uncompressed retryable-admission path
DaemonSessionClientTest.retryableHttpPromptFailureIsAdmissionUnknownAndNotRetried (line 2677) was edited in place from sendJson(exchange, 502, …) to sendEncodedJson(exchange, 502, "gzip", …). That test was the guard for "a retryable HTTP status on prompt admission is outcome-unknown and is never retried" over the normal wire. It now exercises a materially different code path: the new Content-Encoding branch short-circuits in HttpSupport.consume before the body is read, so the original decode-and-classify path is no longer covered for a retryable status.
Enumerating every non-2xx status the tests stub for POST /session/:id/prompt confirms the gap:
| status | uncompressed | gzip |
|---|---|---|
| 202 (success) | ✅ many | ✅ line 2708 |
| 400 (definitive) | ✅ line 1960 | — |
| 429 (definitive) | ✅ line 2751 | — |
| 409 (definitive) | — | ✅ line 2728 |
| 408 / 5xx (retryable) | ❌ none | ✅ line 2681 |
The other 502/503 stubs in the file (lines 1712–1725) are for heartbeat/permission/cancel/delete, not prompt admission.
Suggested fix: restore the original sendJson(exchange, 502, …) body in retryableHttpPromptFailureIsAdmissionUnknownAndNotRetried, and add compressedRetryableHttpPromptFailureIsAdmissionUnknownAndNotRetried as a sibling. Both invariants then have a dedicated guard.
2. (Low — test strength) The three fail-closed epoch tests don't actually prove "no reconnect"
preventsStaleCursorFromAcceptingNewEpochSuffix, failsClosedOnMalformedSseResponseEpoch, and failsClosedOnDuplicateSseResponseEpochHeaders each use a stub that returns the same bad response on every connection. A client that reconnected up to maximumReconnectAttempts would still surface the same PromptOutcomeIndeterminateException, so all three pass either way. preventsStaleCursor… asserts on an AtomicReference that is set() on every request — overwritten, not counted — so it doesn't close the gap either.
Reading the code, the behaviour is right (catch (DaemonProtocolException e) { throw indeterminate(…) } sits outside the reconnect branches). But "fails closed before consuming events, without retrying" is the PR's headline claim, and it is currently unpinned. Adding an AtomicInteger subscriptions and assertEquals(1, subscriptions.get()) to each is a two-line change per test.
3. (Low) SSE error bodies bypass the new Content-Encoding guard
HttpSupport.consume now rejects/annotates non-identity encodings, and validateSseHeaders checks Content-Encoding — but only on the 200 branch. A non-200 SSE response goes through readSseError → HttpSupport.readError(InputStream, …), which has no such check, so a gzip-encoded SSE error body is decoded as raw UTF-8 and lands as mojibake inside the DaemonHttpException message. Cosmetic (classification is by status code and stays correct), but the guard is cheap here and would make the treatment symmetric with the REST path this PR just hardened.
4. (Low — consistency) Retry-After still accepts Unicode digits
The SseReader hardening is a real catch: Long.parseLong delegates to Character.digit, so id: ١ (U+0661) really did parse as 1 before isAsciiDigits. But DaemonSessionClient.retryAfter (line 1103) still does Long.parseLong(value.trim()) on the Retry-After header, so the identical Unicode-digit acceptance survives at that boundary.
Impact is nil in practice — reconnectOrThrow line 910 clamps to Math.min(5000L, …) and then to remainingMillis — so this is a consistency nit, not a bug. Flagging it only because if the intent is "deterministic numeric parsing at every wire boundary", this is the one that got missed.
5. (Low) Require an unchanged source tree still fails open
The PR adds set -euo pipefail to four run blocks in release-sdk-java.yml, but leaves it off Require an unchanged source tree (line 165), which evaluates [[ -n "$(git status --porcelain)" ]]. If git status errors, the substitution is empty, the test is false, and the step passes silently — a release gate failing open.
Worth noting for the PR description too: GHA's default shell on Linux is bash -e {0}, so -e was already active on every one of these steps. The real additions are -u and pipefail, and this step is the one place where the missing error-check actually changes a release-gating decision. (Require protected main at line 31 also lacks it, but has no command substitution, so it's harmless.)
6. (Info) Central-availability worst case vs. the new 60-minute job cap
Verify Maven Central availability went from 30 × 10s (≈5 min) to 40 × (30s sleep + ≤10s curl) → up to ~26 min, inside a timeout-minutes: 60 job that also runs mvn verify, npm ci, build, bundle, the Java E2E, and mvn deploy. Bounding the curl is unambiguously right (an unbounded connect could previously hang the job indefinitely). The note is just that on a slow Central day the job can now hit the wall clock after the tag was pushed and the artifact deployed, leaving only the GitHub Release step un-run. That looks recoverable via the tag_state=resume / artifact_state=published preflight path on a re-run — worth confirming that's the intended recovery story rather than manual cleanup.
Separately: the credential gate's coverage is sound. tag_state == 'new' implies artifact_state == 'missing' (preflight lines 118–121 hard-fail the new + published combination), so every path that can create the immutable tag is preceded by the credential check. That's the important property and it holds.
7. (Nit) detached: true trades one orphan mode for another
Putting the daemon and Maven in their own process groups is what makes the TERM/KILL escalation reach grandchildren — good. The flip side is that a SIGKILL of the runner (or an outer wrapper's kill -9) now leaves them running, where previously a terminal-wide ^C would have reached them via the shared foreground group. The new SIGINT/SIGTERM handlers cover the normal cases and CI container teardown covers the rest, so this is an accepted trade — just worth one line of comment in the script so nobody later "simplifies" the handlers away and reintroduces the orphan.
Nits
DaemonClient.openSse:HttpRequest.Builder request/HttpRequest builtRequestreads awkwardly —builder/requestwould be the conventional pairing.HttpSupport.consume:throw new DaemonProtocolException(\n diagnostic);is wrapped across two lines for no reason (fits on one).docs/design/java-daemon-sdk-alpha.md: the reflow leavescover one-local-prompt admission,stranded on a short line. Harmless (proseWrapispreserve), just untidy.- The BOM's 3 bytes still count toward
maximumFrameBytesfor the first frame. Irrelevant at any realistic limit.
Verdict
Approve after #1. The transport logic is careful and the fail-closed boundary is preserved end-to-end; the epoch work is a genuine fix for the post-#7458 gap and is correctly conservative. #1 is a two-minute fix and is the only one I'd hold the merge for — restoring a deleted regression guard matters more than the new coverage it was traded for. #2–#7 are follow-up material.
中文说明
结论
在 63378a8ab 上审阅了完整 diff(17 文件,+741/−103),并在与当前 main 的合并结果上复核。未发现阻塞性正确性缺陷。 epoch 链路正确,两条回退路径都是 fail-closed,两个新增 bridge 测试经变异验证确实有判别力。以下是一个测试覆盖回归和若干一致性/健壮性问题。
已实测验证的部分
- epoch 契约与 daemon 完全一致:Java 的
[A-Za-z0-9_-]{1,64}+matches()(全串锚定)与sse-last-event-id.ts中parseEventEpochHeader的/^[\w-]+$/+length ≤ 64对齐;daemon 的 epoch 是randomUUID()(eventBus.ts:281)。服务端把非法 header 当作「未提供」、客户端当作「致命」,这个不对称方向对 fail-closed 客户端是正确的。 - 两条回退路径都 fail-closed:daemon 只在 admission 给 epoch、SSE 响应不带 header 时,客户端保留 epoch 继续回传,服务端 stale-cursor 检测发出
state_resync_required,而该事件在SESSION_FAILURE_EVENTS(DaemonSessionClient.java:34-36)中 →indeterminate。 - mismatch 路径无 stream/watchdog 泄漏:
responseEventEpoch在 watchdog 调度和SseReader构造之前执行,位于finally会调用requestStreamClose(stream)的try内,watchdog 取消也有 null 保护。 - 新的 bare-CR
readLine()中mark(1)+ 单次read()+reset()是安全的:BufferedInputStream#fill()使 mark 失效的分支要求markpos == 0 && pos >= buf.length,而 peek 时刻pos == markpos,故 1 字节前瞻下不可达。三种情况的wireBytes计数均正确。BOM 剥离是每连接的(SseReader每次 SSE 打开都新建,DaemonSessionClient.java:742),不会跨重连泄漏。 Content-Encoding守卫不会被真实 daemon 触发:REST 与 SSE 都已发送Accept-Encoding: identity(DaemonClient.java:510),JDKHttpClient既不声明也不自动解压 gzip,qwen serve也没有compression中间件。- bridge.ts 的 latch 重置覆盖所有 prompt 起点:
promptActive = true/activePromptId只在bridge.ts:5439-5440一处赋值,delete紧随其后;在与当前main(含 feat(daemon): add explicit channel delivery #7388)的合并树上复核,仍是唯一一处。 - 变异验证:删掉
delete entry.cancelBroadcastWithoutPrompt;→allows a new idle cancellation after another prompt starts失败(2 → 1);把promptId === undefined && …WithoutPrompt === true换成false→deduplicates repeated cancellation broadcasts while idle失败。两个测试都有判别力。 packages/acp-bridge整文件:444 通过 / 2 失败,且相同的 2 个失败在origin/main基线73279ccfc上同样复现,属本机环境的既有问题,与本 PR 无关。可干净合入当前main。- 本机无 JDK/Maven,未能本地运行 Java 套件;依据 CI——该 head 上 ubuntu 11/17/21、macOS 21、Windows 21 及真实 daemon E2E 全绿。
问题
- (Medium — 测试覆盖)既有回归守卫被就地改写,未压缩的可重试 admission 路径失去覆盖。
retryableHttpPromptFailureIsAdmissionUnknownAndNotRetried(2677 行)从sendJson(exchange, 502, …)就地改为sendEncodedJson(exchange, 502, "gzip", …)。新的Content-Encoding分支在读 body 之前短路,因此该测试现在走的是另一条路径。穷举所有为POST /session/:id/prompt打桩的非 2xx 状态可确认:400、429/409、202 都有未压缩覆盖,408 / 5xx 的未压缩路径已无任何测试(1712–1725 行的 502/503 属于 heartbeat/permission/cancel/delete,不是 prompt admission)。建议恢复原测试并另加 gzip 变体作为兄弟用例。 - (Low — 测试强度)三个 fail-closed epoch 测试并未证明「不重连」。
preventsStaleCursorFromAcceptingNewEpochSuffix、failsClosedOnMalformedSseResponseEpoch、failsClosedOnDuplicateSseResponseEpochHeaders的桩每次连接都返回同样的坏响应,即使客户端重连到上限也会抛出同一异常,测试仍会通过;preventsStaleCursor…断言的AtomicReference每次请求都被覆盖而非计数。代码行为本身是对的,但「在消费事件前 fail closed 且不重试」是本 PR 的核心主张,目前未被钉死。每个测试加一个AtomicInteger subscriptions与assertEquals(1, …)即可。 - (Low)SSE 错误响应体绕过了新的
Content-Encoding守卫。validateSseHeaders只在 200 分支执行;非 200 走readSseError→HttpSupport.readError(InputStream, …),没有该检查,gzip 错误体会以原始 UTF-8 解码成乱码进入DaemonHttpException消息。仅影响可读性(状态码分类仍正确),但补上成本很低,可与刚加固的 REST 路径对称。 - (Low — 一致性)
Retry-After仍接受 Unicode 数字。SseReader的加固是真问题(Long.parseLong走Character.digit,id: ١之前确实会被解析成1);但DaemonSessionClient.retryAfter(1103 行)仍是Long.parseLong(value.trim())。因 910 行有Math.min(5000L, …)及remainingMillis双重钳制,实际影响为零,仅作一致性提示。 - (Low)
Require an unchanged source tree仍会 fail open。 本 PR 给 4 个run块加了set -euo pipefail,但 165 行这个步骤没加,其[[ -n "$(git status --porcelain)" ]]在git status出错时命令替换为空 → 条件为假 → 静默通过,这是一个会 fail open 的发布闸门。另外 PR 描述可修正:GHA 在 Linux 上默认 shell 是bash -e {0},-e本就生效,真正新增的是-u与pipefail。 - (Info)Central 可用性轮询的最坏耗时 vs. 新的 60 分钟 job 上限。 从 30 × 10s(约 5 分钟)变为 40 × (30s sleep + ≤10s curl),最坏约 26 分钟,而同一个 60 分钟的 job 还要跑
mvn verify、npm ci、build、bundle、Java E2E 和mvn deploy。给 curl 加上界是明确正确的(此前无界连接可能永久挂住 job);提示点仅在于 Central 慢的时候,job 可能在 tag 已推送、制品已部署之后耗尽时间,只剩 GitHub Release 未执行。看起来可通过 preflight 的tag_state=resume/artifact_state=published重跑恢复,建议确认这就是预期的恢复路径。另:凭证闸门的覆盖是完备的——preflight 118–121 行硬失败了new+published组合,因此tag_state == 'new'蕴含artifact_state == 'missing',所有能创建不可变 tag 的路径都先过了凭证检查。 - (Nit)
detached: true是一种孤儿模式的置换。 让 daemon 与 Maven 独立成组,正是 TERM/KILL 升级能触达孙进程的前提;代价是 runner 被SIGKILL时它们会残留,而此前共享前台进程组时终端^C能触达。新增的信号处理覆盖了常规场景,CI 容器回收覆盖其余,属可接受权衡——建议在脚本里加一行注释,避免日后有人「简化」掉信号处理而重新引入孤儿进程。
小问题
DaemonClient.openSse:HttpRequest.Builder request/HttpRequest builtRequest命名别扭,builder/request更常规。HttpSupport.consume:throw new DaemonProtocolException(\n diagnostic);无谓换行。docs/design/java-daemon-sdk-alpha.md:重排后cover one-local-prompt admission,单独占一短行。无害(proseWrap为preserve),仅不整洁。- 首帧的 BOM 3 字节仍计入
maximumFrameBytes,在任何现实上限下都无影响。
结论
修完 #1 即可合入。 transport 逻辑严谨,fail-closed 边界端到端保持;epoch 工作确实修复了 #7458 之后的缺口且保守得当。#1 是两分钟的修改,也是我唯一会因此拦住合并的一项——恢复被删掉的回归守卫,比它换来的新覆盖更重要。#2–#7 可作为后续处理。
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Thanks @wenshao — addressed the merge-blocking coverage regression in
Verification: |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. LGTM! ✅
— qwen3.7-max via Qwen Code /review
Code Review —
|
| Status | Uncompressed | gzip |
|---|---|---|
| 202 (admission) | ✅ (many) | ✅ compressedPromptAdmissionIsOutcomeUnknown |
| 409 (definitive) | ✅ definitivePromptRejectionRemainsHttpError (429) / 400 |
✅ compressedDefinitivePromptRejectionRemainsHttpError |
| 502 (retryable) | ✅ retryableHttpPromptFailureIsAdmissionUnknownAndNotRetried |
✅ compressedRetryableHttpPromptFailureIsAdmissionUnknownAndNotRetried |
Confirmed the restored test again uses uncompressed sendJson(exchange, 502, …) and asserts session-1/prompt is hit exactly once (no retry) → PromptAlreadyActiveException on reuse.
Verified-correct (tried to break, couldn't)
- Epoch grammar matches the server exactly — Java
[A-Za-z0-9_-]{1,64}+matches()↔ serverparseEventEpochHeader(/^[\w-]+$/, len ≤ 64); epochs arerandomUUID(). Client-fatal / server-treats-invalid-as-absent is the correct asymmetry. - Epoch mismatch fails closed — the mid-observation change throws
DaemonProtocolExceptionbefore events are consumed and surfaces asPromptOutcomeIndeterminateException(never a silent retry), asserted bypreventsStaleCursorFromAcceptingNewEpochSuffix(single request, epochepoch-old). - Missing SSE header → indeterminate —
state_resync_requiredis inSESSION_FAILURE_EVENTS, so the numeric-only fallback stays fail-closed. - Bare-CR
readLine()is safe —BufferedInputStream.mark(1)+ peek +reset()cannot hitfill()'s mark-invalidation (pos == markposat the peek). - Content-Encoding guard can't be tripped by the real daemon —
Accept-Encoding: identityis already sent on REST, the JDKHttpClientnever auto-gzips, andqwen servehas no compression middleware; the guard is defense against a misconfigured proxy. - Unicode-digit fix is real —
Character.digitused to acceptid: ١;isAsciiDigitsnow rejects it. - E2E signal handling —
detached:trueputs children in their own process group and Ctrl+C now reaches them viaprocess.kill(-pid, …)with TERM→KILL escalation; the runner re-raises the received signal for a correct exit status and reports run + cleanup failures together viaAggregateError.
Non-blocking observations (Low / Info — no change requested)
- Release-workflow consistency: the PR adds
set -euo pipefailto four run steps but skipsRequire an unchanged source tree(still relies on the defaultbash -eshell +[[ -n "$(git status --porcelain)" ]]). Harmless — the default Linux shell already applies-e; adding it here would only be for-u/pipefailconsistency. - SSE error bodies still bypass the encoding guard (
readSseError→HttpSupport.readError;validateSseHeadersis 200-only) — cosmetic, since a compressed error body only affects a diagnostic string. - The
Retry-AfterHTTP-header parse still usesLong.parseLongon Unicode digits — harmless, clamped to 5 s. - Central-availability worst case grew from ~5 min to ~20 min (40 × 30 s) under the new 60-min job cap — intentional and within budget.
Summary
Clean, well-tested, fail-closed transport hardening with no breaking API surface (getEventEpoch() is additive, may be null for older daemons). The one gap from last round is closed and the coverage is now symmetric. Approve.
中文说明
在 head 0b5335638 复审。结论:✅ 可合并。 上一轮唯一的阻塞项已完全解决;CI 在 Java 矩阵(Ubuntu 11/17/21、macOS 21、Windows 21)与真实 daemon E2E 上全绿。
PR 内容:#7463 的后续,使 Java daemon SDK 适配 #7458 的重启安全事件游标契约,并补齐审查 #7463 时发现的 wire-level 可靠性项:事件 epoch 传播与 fail-closed、SSE 裸 CR/BOM/CRLF 计数/非 ASCII 游标数字、REST 压缩响应拒绝、截断 JSON 判定、bridge 空闲 cancel 去重、CI/发布/E2E 加固。
上一轮阻塞项已解决(测试覆盖):retryableHttpPromptFailureIsAdmissionUnknownAndNotRetried 之前被就地改成 gzip 变体,导致没有测试覆盖未压缩的 5xx admission 路径(新的 Content-Encoding 守卫在读 body 前短路,是不同代码路径)。最后一个 commit 恢复了原测试(仍用未压缩 sendJson(502)),并把 gzip 用例作为兄弟测试新增。admission 失败矩阵现已在压缩/未压缩两个维度上完整(202/409/502)。
已验证正确(尝试证伪未果):epoch 语法与服务端完全一致;epoch mismatch 在消费事件前 fail closed → PromptOutcomeIndeterminateException;缺失 SSE header 走 state_resync_required 保持 indeterminate;裸 CR readLine() 的 mark(1)/reset() 安全;Content-Encoding 守卫无法被真实 daemon 触发;Unicode 数字修复真实有效;E2E 信号处理正确(进程组 TERM→KILL 升级 + 重新抛出信号)。
非阻塞(Low/Info,不要求改动):Require an unchanged source tree 步骤未加 set -euo pipefail(一致性,非功能问题,默认 shell 已有 -e);SSE error body 绕过编码守卫(仅影响诊断字符串);Retry-After 仍用 Long.parseLong(无害,钳制到 5s);Central 轮询最坏耗时约 5min→20min(有意,预算内)。
总结:干净、测试充分、fail-closed 的 transport 加固,无破坏性 API(getEventEpoch() 为新增,旧 daemon 可能返回 null)。上一轮的覆盖缺口已闭合。Approve。
What this PR does
This follow-up to #7463 adapts the Java daemon SDK to the restart-safe event cursor contract introduced by #7458. Prompt admission captures the optional
eventEpochpaired withlastEventId, exposes it throughPromptAcceptance, and sends it asX-Qwen-Event-Epochon the initial SSE request and every reconnect. The client validates epoch tokens, can learn an epoch from a validated SSE response, retains a known epoch when a response omits the header, and fails closed before consuming events if the daemon reports a different epoch.The Java transport now also handles the remaining validated wire-level reliability follow-ups: bare carriage-return SSE line endings and a leading UTF-8 BOM are accepted, non-ASCII cursor digits and malformed or truncated JSON are rejected deterministically, CRLF bytes count against the SSE frame limit, and unsupported REST response compression cannot be mistaken for a valid response. Non-success compressed responses preserve their HTTP status classification, while an undecodable successful prompt admission remains outcome-unknown rather than being retried.
Repeated id-less cancellation broadcasts are deduplicated while an ACP session is idle, and the latch resets when the next prompt starts. Java SDK CI jobs now have explicit time limits. The release workflow validates all Maven Central and GPG credentials before creating an immutable tag, uses bounded Central availability polling, and fails consistently on shell pipeline errors. The real-daemon E2E runner handles SIGINT and SIGTERM, bounds the Maven test, terminates detached POSIX process groups with TERM/KILL escalation, and reports both test and cleanup failures when they occur together.
Fault-injection tests cover admission propagation, response-header learning, reconnect retention, malformed epochs, stale cursors, SSE framing variants, truncated JSON, compressed 2xx/4xx/5xx responses, and idle cancel deduplication. The real-daemon E2E confirms that the current
qwen serveprompt admission supplies an epoch and reaches a reliable terminal.Why it's needed
After #7458, the daemon can distinguish an SSE cursor from a previous event-bus epoch only when the client returns the epoch paired with that cursor. The Java SDK previously ignored the new admission field and response header, so a reconnect after daemon restart could rely only on numeric event IDs and silently skip or accept events from the wrong epoch once the new bus caught up to the stale cursor.
The additional follow-ups close concrete failure modes found while reviewing #7463: standards-compliant SSE variants could be rejected or under-counted, truncated JSON could receive a misleading error, compressed REST responses could corrupt admission/error classification, repeated idle cancel requests could emit duplicate events, and failed or interrupted CI/E2E runs could leave child processes behind. Release credentials were also checked only after a tag could already have been created. The changes preserve the alpha SDK's fail-closed boundary without adding automatic snapshot/resync or claiming exactly-once execution across daemon restarts.
Reviewer Test Plan
How to verify
packages/sdk-java/qwencode, runmvn --batch-mode --no-transfer-progress clean test checkstyle:check. Expect 121 tests with zero failures or errors, five environment-dependent tests skipped, and zero Checkstyle violations.packages/acp-bridge, runnpx vitest run src/bridge.test.ts. Expect all 446 tests to pass, including repeated idle cancellation deduplication and reset after a new prompt starts.npm run build && npm run typecheck && npm run bundle && npx tsx scripts/run-java-daemon-sdk-e2e.ts. Expect four Java E2E scenarios to pass against a real localqwen serve, with a non-null event epoch and reliable prompt terminals.Current CI evidence for commit
254e37362d: the Java SDK matrix passed on Ubuntu with Java 11, 17, and 21, and on macOS and Windows with Java 21; the real-daemon E2E passed on Ubuntu with Java 11.Evidence (Before & After)
N/A — this is a non-UI transport, bridge, CI, and release reliability change.
Tested on
Environment (optional)
macOS Darwin 25.4.0 arm64, Java 21.0.8 LTS, Node.js 22.22.3, and npm 10.9.8.
Risk & Scope
PromptAcceptance#getEventEpoch()is additive and may returnnullfor older daemons.Linked Issues
Related: #7458, #7463
中文说明
此 PR 的内容
这是 #7463 的后续补丁,使 Java daemon SDK 适配 #7458 引入的重启安全事件游标契约。prompt admission 现在会读取与
lastEventId配对的可选eventEpoch,通过PromptAcceptance暴露该值,并在首次 SSE 请求和每次重连时通过X-Qwen-Event-Epoch回传。客户端会校验 epoch token,可以从已验证的 SSE 响应中学习 epoch,在响应缺少 header 时保留已知 epoch,并在 daemon 返回不同 epoch 时于消费事件前 fail closed。Java transport 也补齐了其余经过验证的 wire-level 可靠性项:接受仅使用回车符的 SSE 换行和开头的 UTF-8 BOM;确定性拒绝非 ASCII 游标数字以及非法或截断的 JSON;将 CRLF 的两个字节都计入 SSE frame 上限;不再把不支持的 REST 响应压缩误认为有效响应。压缩的非成功响应会保留其 HTTP 状态分类,而无法解码的成功 prompt admission 会返回结果未知,且不会自动重试。
ACP session 空闲时重复的无 prompt ID 取消广播会被去重,并在下一个 prompt 开始时重置去重状态。Java SDK CI job 增加了明确的时间上限。发布工作流会在创建不可变 tag 前校验全部 Maven Central 和 GPG 凭证,使用有界的 Central 可用性轮询,并在 shell pipeline 出错时一致失败。真实 daemon E2E runner 会处理 SIGINT 和 SIGTERM、限制 Maven 测试时长、通过 TERM/KILL 升级关闭独立的 POSIX 进程组,并在测试与清理同时失败时同时报告两者。
故障注入测试覆盖 admission 传播、响应头学习、重连保留、非法 epoch、旧游标、SSE framing 变体、截断 JSON、压缩的 2xx/4xx/5xx 响应以及空闲取消去重。真实 daemon E2E 确认当前
qwen serve的 prompt admission 会提供 epoch,并能到达可靠终态。为什么需要此改动
#7458 合入后,只有客户端回传与 SSE 游标配对的 epoch,daemon 才能识别该游标是否来自上一轮 event-bus epoch。Java SDK 之前忽略了新增的 admission 字段和响应 header,因此 daemon 重启后的重连只能依赖数字事件 ID;当新 event bus 追上旧游标时,客户端可能静默跳过或接受错误 epoch 的事件。
其余 follow-up 关闭了审查 #7463 时发现的具体失败模式:符合标准的 SSE 变体可能被拒绝或少计字节,截断 JSON 可能得到误导性错误,压缩 REST 响应可能破坏 admission/错误分类,重复的空闲 cancel 请求可能产生重复事件,失败或中断的 CI/E2E 运行可能遗留子进程。此外,发布凭证此前要到 tag 可能已经创建后才会被检查。这些改动保持 alpha SDK 的 fail-closed 边界,不新增自动 snapshot/resync,也不宣称跨 daemon 重启 exactly-once。
Reviewer 验证计划
验证方法
packages/sdk-java/qwencode运行mvn --batch-mode --no-transfer-progress clean test checkstyle:check。预期 121 个测试零失败、零错误,跳过 5 个依赖环境的测试,Checkstyle 零违规。packages/acp-bridge运行npx vitest run src/bridge.test.ts。预期 446 个测试全部通过,包括重复空闲取消去重,以及新 prompt 开始后重置去重状态。npm run build && npm run typecheck && npm run bundle && npx tsx scripts/run-java-daemon-sdk-e2e.ts。预期 4 个 Java E2E 场景在真实本地qwen serve上通过,包含非空 event epoch 和可靠 prompt 终态。提交
254e37362d的当前 CI 证据:Java SDK 矩阵已在 Ubuntu 的 Java 11、17、21,以及 macOS 和 Windows 的 Java 21 上通过;真实 daemon E2E 已在 Ubuntu 的 Java 11 上通过。证据(Before & After)
N/A——这是非 UI 的 transport、bridge、CI 和发布可靠性改动。
测试平台
环境(可选)
macOS Darwin 25.4.0 arm64、Java 21.0.8 LTS、Node.js 22.22.3、npm 10.9.8。
风险与范围
PromptAcceptance#getEventEpoch(),连接旧 daemon 时可能返回null。关联事项
相关:#7458、#7463