Skip to content

refactor(cli): Split serve server routes - #5809

Merged
wenshao merged 13 commits into
QwenLM:mainfrom
doudouOUC:codex/serve-server-split
Jun 26, 2026
Merged

refactor(cli): Split serve server routes#5809
wenshao merged 13 commits into
QwenLM:mainfrom
doudouOUC:codex/serve-server-split

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

This PR keeps the qwen serve daemon app as the composition point while moving cohesive request handling, response mapping, telemetry, filesystem, auth provider, session listing, prompt deadline, and route registration responsibilities into focused internal modules. It preserves the existing middleware and route ordering, compatibility exports, HTTP response contracts, SSE framing, and daemon protocol behavior.

Why it's needed

Issue #5576 calls out the serve daemon implementation as too large to maintain safely. This first split reduces the central file while keeping behavior stable, so later route extraction can happen against clearer boundaries without changing daemon protocol behavior.

Reviewer Test Plan

How to verify

Run the focused serve tests with low concurrency to avoid server-heavy test interference: cd packages/cli && npx vitest run src/serve/server.test.ts --maxWorkers=1 --maxConcurrency=1 --no-file-parallelism and cd packages/cli && npx vitest run src/serve/acp-http/*.test.ts src/serve/routes/*.test.ts --maxWorkers=1 --maxConcurrency=1 --no-file-parallelism. Also run npm run typecheck, npm run build, and npm run lint:ci. Expected result: all commands pass and the daemon route responses remain unchanged because this PR is structural only.

Evidence (Before & After)

N/A; this is a non-UI refactor with no user-visible behavior change.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

macOS local checkout with Node v22.22.3 and npm 10.9.8.

Risk & Scope

  • Main risk or tradeoff: Large mechanical extraction can accidentally move Express middleware or route ordering, so this PR keeps the daemon app as the only composer and validates the route-heavy serve tests.
  • Not validated / out of scope: This does not split remaining MCP runtime mutation routes, remove compatibility re-export shims, or change daemon protocol behavior.
  • Breaking changes / migration notes: None expected; public compatibility exports are preserved.

Linked Issues

Refs #5576

中文说明

What this PR does

本 PR 保留 qwen serve daemon app 作为装配点,同时把请求处理、错误响应映射、遥测、文件系统、鉴权 provider、会话列表、prompt deadline 和 route 注册等职责移动到更聚焦的内部模块。现有 middleware 和 route 顺序、兼容导出、HTTP 响应契约、SSE 帧格式以及 daemon 协议行为都会保持不变。

Why it's needed

issue #5576 指出 serve daemon 实现过大,后续维护风险高。本阶段先在行为稳定的前提下缩小中心文件,让后续 route 继续下沉时可以基于更清晰的边界推进,而不需要改变 daemon 协议行为。

Reviewer Test Plan

How to verify

用低并发方式运行 focused serve 测试以避开 server-heavy 测试互相干扰:cd packages/cli && npx vitest run src/serve/server.test.ts --maxWorkers=1 --maxConcurrency=1 --no-file-parallelismcd packages/cli && npx vitest run src/serve/acp-http/*.test.ts src/serve/routes/*.test.ts --maxWorkers=1 --maxConcurrency=1 --no-file-parallelism。同时运行 npm run typechecknpm run buildnpm run lint:ci。预期所有命令通过,并且 daemon route 响应保持不变,因为本 PR 只做结构重构。

Evidence (Before & After)

N/A;这是非 UI 重构,没有用户可见行为变化。

Tested on

OS Status
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

Environment (optional)

macOS 本地 checkout,Node v22.22.3,npm 10.9.8。

Risk & Scope

  • Main risk or tradeoff: 大规模机械抽取可能意外移动 Express middleware 或 route 顺序,所以本 PR 保持 daemon app 作为唯一装配点,并用 route-heavy serve 测试验证。
  • Not validated / out of scope: 本 PR 不拆剩余 MCP runtime mutation routes,不删除兼容 re-export shim,也不改变 daemon 协议行为。
  • Breaking changes / migration notes: 预计没有;公开兼容导出继续保留。

Linked Issues

Refs #5576

@doudouOUC
doudouOUC marked this pull request as ready for review June 24, 2026 08:23
Copilot AI review requested due to automatic review settings June 24, 2026 08:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread packages/cli/src/serve/routes/workspace-auth.ts
Comment thread packages/cli/src/serve/routes/workspace-auth.ts
@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

On direction: this directly addresses #5576 — the 5715-line server.ts god file was explicitly called out as the largest KISS violation in the serve daemon. Splitting it into focused modules with clear boundaries (request helpers, error response, telemetry, session list, fs factory, prompt deadline) is exactly the right first step. Aligned with the project's maintainability goals.

On approach: the scope feels right for a first-stage split. The extracted boundaries are cohesive — each module owns a single concern, server.ts remains the sole composer, and middleware ordering is explicitly preserved. Two minor drive-by formatting changes (line wrapping in pipeline.ts, collapsed call in nonInteractiveCliCommands.test.ts) are cosmetic and unrelated to the split; not blocking but worth noting. The safeLogValue consolidation from workspace-agents.ts into request-helpers.ts and the CLIENT_ID_RE deduplication into rate-limit.ts are clean wins from the extraction.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向:直接解决 #5576 — 5715 行的 server.ts 是 serve daemon 中最大的 KISS 问题。将其拆分为有清晰边界的聚焦模块(请求辅助、错误响应、遥测、会话列表、文件系统工厂、提示截止时间)是正确的第一步,与项目的可维护性目标一致。

方案:作为第一阶段拆分,范围合理。抽取的边界内聚——每个模块只负责一个关注点,server.ts 仍然是唯一的装配点,middleware 顺序被显式保留。两处小的顺手格式化改动(pipeline.ts 换行、nonInteractiveCliCommands.test.ts 合并调用)是装饰性的,与拆分无关;不构成阻碍但值得注意。safeLogValueworkspace-agents.ts 整合到 request-helpers.ts 以及 CLIENT_ID_RE 去重到 rate-limit.ts 都是抽取带来的干净收益。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

2a. Code Review

Independent proposal (before reading the diff): to split a 5715-line server.ts, I'd extract helpers into a server/ subdirectory and route groups into routes/. Each route module gets a registerXRoutes(app, deps) function. Shared utilities (error taxonomy, request body sanitization, client-id parsing) go into the server/ helpers. server.ts becomes the sole composer that wires middleware and calls each registerXRoutes. Middleware ordering must stay byte-identical.

Comparison: the PR's approach matches this exactly. server/ holds 7 focused helper modules (request-helpers, error-response, fs-factory, session-list, telemetry, prompt-deadline, auth-provider-helpers). Route modules follow registerXRoutes(app, deps) in routes/. server.ts is the composer at 1315 lines (down from 5715). Middleware ordering is preserved — the 14-step sequence (same-origin strip → CORS → host allowlist → pre-auth health/demo → access log → web shell → bearer auth → rate limit → JSON parser → post-auth health/demo → telemetry → routes → ACP HTTP/WS → web shell fallback → error handler) is intact.

Reuse check: safeLogValue was duplicated between server.ts and workspace-agents.ts — now consolidated into request-helpers.ts. CLIENT_ID_RE/MAX_CLIENT_ID_LENGTH was duplicated in rate-limit.ts — now imported from request-helpers.ts. All clean deduplication, no over-abstraction.

Correctness: no bugs found. Error taxonomy lives in one place (error-response.ts), preventing status code drift. Compatibility re-exports preserve the public API consumed by run-qwen-serve.ts and tests.

Minor scope creep (non-blocking): pipeline.ts line wrapping and nonInteractiveCliCommands.test.ts formatting collapse are drive-by cosmetic changes unrelated to the split. Not worth blocking on.

2b. Verification (re-run 2026-06-26, PR HEAD 41cabf406)

Build, Typecheck, Lint

npm run build       → exit 0 (15 pre-existing warnings in vscode-ide-companion, 0 errors)
npm run typecheck   → exit 0 (all 5 packages)
npm run lint:ci     → exit 0 (0 warnings)

Unit Tests

cd packages/cli && npx vitest run src/serve/server.test.ts \
  --maxWorkers=1 --maxConcurrency=1 --no-file-parallelism

 Test Files  1 passed (1)
      Tests  529 passed (529)
   Duration  19.80s
cd packages/cli && npx vitest run src/serve/acp-http/*.test.ts src/serve/routes/*.test.ts \
  --maxWorkers=1 --maxConcurrency=1 --no-file-parallelism

 Test Files  11 passed (11)
      Tests  316 passed (316)
   Duration  32.07s

Total: 845 tests passed, 0 failed.

Tmux Daemon Smoke Test

Booted npm run dev -- serve --port 4190 --no-web --workspace /tmp/triage-ws-5809 from the PR worktree:

qwen serve: daemon log → ~/.qwen-home/debug/daemon/serve-2519772-4b04f3e2.log
qwen serve listening on http://127.0.0.1:4190 (mode=http-bridge, workspace=/tmp/triage-ws-5809)
qwen serve: bound to workspace "/tmp/triage-ws-5809"
qwen serve: startup timing: processToListenMs=1401 runQwenServeToListenMs=29
qwen serve: bearer auth disabled (loopback default). Set QWEN_SERVER_TOKEN to enable.
qwen serve: session reaper started (interval 60000ms, idle threshold 1800000ms)
qwen serve: /acp WebSocket transport enabled on /acp

Probed extracted route modules:

Module Route Response
health-demo GET /health 200 {"status":"ok"}
(inline) GET /capabilities 200 full feature list
daemon-status GET /daemon/status?detail=summary 200 {"v":1,"detail":"summary","status":"ok","issues":[]}
session-list GET /workspace/:id/sessions 200 {"sessions":[]}
workspace-auth GET /workspace/auth/status 200 {"v":1,"providers":[],"pendingDeviceFlows":[]}
session POST /session (real session) 200 {"sessionId":"cd9612b6-...","workspaceCwd":"/tmp/triage-ws-5809"}
error-response GET /nonexistent 404 HTML error page
permission POST /permission/fake-id bad body 400 {"error":"outcome must be..."}

Daemon telemetry log confirms all routes served:

[DAEMON] route=GET /capabilities durationMs=3 status=200 request completed
[DAEMON] route=GET /daemon/status durationMs=1 status=200 request completed
[DAEMON] route=GET /workspace/%2Ftmp%2Ftriage-ws-5809/sessions durationMs=2 status=200 request completed
[DAEMON] route=POST /session durationMs=41 status=200 request completed
[DAEMON] route=GET /workspace/auth/status durationMs=0 status=200 request completed
[DAEMON] route=POST /permission/fake-id durationMs=1 status=400 request completed

Verdict: identical behavior to main — same startup sequence, same middleware stack, same response shapes. All extracted daemon routes respond correctly.

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Reflection (re-verified 2026-06-26)

Re-running triage on current HEAD 41cabf406. The prior review (@wenshao APPROVED) and maintainer local verification (845 tests, daemon smoke test) are in place. Fresh local verification confirms:

  • Build + typecheck clean across all 5 packages, lint clean (0 warnings)
  • 845 tests pass (529 server + 316 route/ACP HTTP), 0 failures
  • Daemon starts from PR worktree, responds to all tested endpoints correctly — health, capabilities, daemon status, session creation, workspace auth, session listing, permission error taxonomy, and 404 error response all working
  • Middleware ordering verified by reading the createServeApp() assembly — 14-step sequence intact

My independent proposal was: extract into server/ helpers + routes/ modules, keep server.ts as composer, preserve middleware order. The PR matches this exactly. The extraction boundaries are clean — each module has a single concern, dependency injection via deps objects, no circular imports. server.ts went from 5715 to 1315 lines, which is a meaningful reduction while keeping the MCP mutation routes and capabilities endpoint inline (rightly so — they're tightly coupled to the app assembly).

The two drive-by formatting changes (pipeline.ts line wrap, test file formatting collapse) are cosmetic noise but trivial. Not worth blocking on.

Approval guardrail: this is a cross-repository (fork) PR with a refactor title. Per policy, I cannot auto-approve — refactors touch structure broadly and need a human maintainer's sign-off. The code review is clean and I have no blocking concerns, but the approve/reject decision belongs to a maintainer.

@doudouOUC — thanks for the thorough work on this split. The design doc, test plan, and preserved middleware ordering make this straightforward to review. Escalating to a maintainer for the final approve/reject call.

中文说明

反思(2026-06-26 重新验证)

在当前 HEAD 41cabf406 上重新运行 triage。之前的审查(@wenshao APPROVED)和维护者本地验证(845 个测试、daemon smoke test)已到位。最新的本地验证确认:

  • 所有 5 个包的 build + typecheck 干净通过,lint 干净(0 warnings)
  • 845 个测试全部通过(529 server + 316 route/ACP HTTP),0 失败
  • Daemon 从 PR worktree 启动,所有测试端点响应正确——health、capabilities、daemon status、session 创建、workspace auth、session 列表、permission 错误分类、404 错误响应均正常
  • 通过阅读 createServeApp() 装配过程验证了 middleware 顺序 — 14 步序列完整

我的独立方案是:抽取到 server/ 辅助模块 + routes/ 模块,保留 server.ts 作为装配点,保持 middleware 顺序。PR 完全匹配这个方案。抽取边界干净——每个模块单一关注点,通过 deps 对象注入依赖,没有循环引用。server.ts 从 5715 行降到 1315 行,这是一个有意义的缩减,同时保留了 MCP mutation routes 和 capabilities 端点内联(合理的——它们与 app 装配紧密耦合)。

两处顺手格式化改动(pipeline.ts 换行、测试文件格式合并)是装饰性噪音,不值得阻碍。

审批护栏: 这是来自 fork 的 refactor PR。按策略,我不能自动批准——重构广泛触及结构,需要人类维护者的签字。代码审查干净,我没有阻碍性问题,但 approve/reject 决定属于维护者。

@doudouOUC — 感谢在这个拆分上的细致工作。设计文档、测试计划和保留的 middleware 顺序让审查变得直接。已转交维护者做最终 approve/reject 决定。

Qwen Code · qwen3.7-max

@wenshao wenshao 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.

Clean mechanical extraction of the server.ts monolith into focused modules. All 770 tests pass (518 server + 251 route/integration), build succeeds, typecheck and ESLint clean. Nine review agents (correctness, security, code quality, performance, test coverage, attacker/oncall/maintainer personas) plus a reverse audit found zero behavioral regressions — middleware ordering, closure captures, re-exports, class identity, and all input validation are byte-for-byte preserved.

Downgraded from Approve to Comment: CI failing (CodeQL).

— qwen3.7-max via Qwen Code /review

@doudouOUC doudouOUC self-assigned this Jun 24, 2026
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC
doudouOUC force-pushed the codex/serve-server-split branch from a6af3fb to af2fc29 Compare June 24, 2026 12:19
Comment thread packages/cli/src/serve/routes/session.ts Outdated
Comment thread packages/cli/src/serve/routes/workspace-status.ts Outdated
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@wenshao wenshao 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.

⚠️ Downgraded from Approve to Comment: CI still running. Substantively this is a clean approve — the COMMENT event is only because remote CI hadn't finished at review time.

Verified behavior-faithful mechanical extraction — no functional findings.

What I checked: byte-level comparison of every extracted function against the pre-split server.ts; middleware + route registration order (matches the design doc's required sequence, verified by reconstructing the full ordered route list for both revisions); error taxonomy / status codes / headers; validators and length caps; the SSE activeSseCount shared counter (single instance, not forked); and the lazy getter wiring (getAcpHandle / getRateLimiter / currentServeFeatures invoked per-request, so deps assigned after registration still resolve). Deterministic checks pass first-hand on HEAD ad4f097: tsc --noEmit clean, and server.test.ts + acp-http/routes suites = 769 tests green.

The latest commit also cleanly resolves the two earlier review comments — SendBridgeError is now a single exported type in error-response.ts (replacing the five duplicated local aliases + the inline one in server.ts), and the duplicated buildWorkspaceCtx closure is consolidated into makeBuildWorkspaceCtx. Both are behavior-preserving.

One minor non-blocking follow-up (out-of-diff, optional): now that CLIENT_ID_RE / MAX_CLIENT_ID_LENGTH moved out of server.ts into server/request-helpers.ts, the // Keep in sync with server.ts CLIENT_ID_RE / MAX_CLIENT_ID_LENGTH breadcrumb in packages/cli/src/serve/rate-limit.ts:95 now points at a file where those constants no longer live — worth repointing to request-helpers.ts. The duplicated values themselves are unchanged, so there's no behavioral drift today.

— claude-opus-4-8[1m] via Qwen Code /qreview

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@qwen-code-ci-bot qwen-code-ci-bot 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.

Clean structural refactor — behavioral equivalence verified across all 60+ routes, middleware ordering, SSE lifecycle, error taxonomy, and auth gates. 769 tests pass, typecheck and lint clean. No issues found. Downgraded from Approve to Comment: CI still running.

— qwen3.7-max via Qwen Code /review

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

I checked the two CodeQL js/missing-rate-limiting threads. POST /workspace/auth/provider is registered after the global rateLimiter.middleware in createServeApp, and rate-limit.ts maps non-GET/HEAD routes to the mutation tier, so the route is covered when --rate-limit is enabled. This PR only moves the existing handler out of server.ts; it does not introduce a new route ordering or rate-limit bypass.

One caveat: rate limiting remains an opt-in daemon setting. If the project policy requires this auth-provider install route to be rate-limited even when global rate limiting is disabled, that would be a broader hardening change rather than a regression from this refactor. Otherwise, I think these GHAS comments can be treated as CodeQL not recognizing the global configurable middleware and should be triaged by maintainers/security.

中文说明

我核对了两个 CodeQL js/missing-rate-limiting 线程。POST /workspace/auth/providercreateServeApp 中注册于全局 rateLimiter.middleware 之后,并且 rate-limit.ts 会把非 GET/HEAD 路由归入 mutation 限流层;开启 --rate-limit 时该路由会被全局限流覆盖。本 PR 只是把原来 server.ts 中已有的处理逻辑迁移到路由文件,没有引入新的路由顺序或限流绕过。

需要注意的是,限流本身仍是 daemon 的可选配置。如果项目策略要求该 auth-provider install 路由即使在未开启全局限流时也必须限流,那属于更大的安全加固决策,而不是本次重构引入的回归。否则我认为这两个 GHAS 评论可以按 CodeQL 未识别全局可配置中间件来处理,由维护者/security 做 triage。

@qwen-code-ci-bot qwen-code-ci-bot 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.

No review findings. Downgraded from Approve to Comment: CI still running.

Summary: Clean structural refactor that extracts a 5700-line server.ts into 16 focused modules. Behavioral equivalence verified — middleware ordering, error contracts, SSE framing, and auth gates all preserved. Build passes, all 518 serve tests pass, typecheck and lint clean.

Needs Human Review (low confidence):

  • workspace-extensions.ts: safeBody received via deps while sibling modules import it directly (minor DI inconsistency)
  • workspace-agents.ts:687-696: stale comment referencing safeLogValue location and visibility (not in diff, oversight during refactor)

— qwen3.7-max via Qwen Code /review

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Comment thread packages/cli/src/serve/routes/session.ts Outdated
@wenshao

wenshao commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

✅ Local real-daemon verification — PR #5809

Verdict: structural-only refactor, behavior preserved end-to-end — safe to merge.

I built the real qwen binary at PR head (e57561b20) in an isolated worktree, ran the PR's claimed suites, statically verified the refactor invariants (exports / middleware order / route set), and then stood up the actual qwen serve daemon in tmux to confirm every split route still responds — including a byte-for-byte A/B of HTTP responses against a base-commit daemon.


1. Build & checks (PR's claimed commands)

Command Result
npm ci && npm run build exit 0
vitest run src/serve/server.test.ts (low concurrency) 518 passed
vitest run src/serve/acp-http/*.test.ts src/serve/routes/*.test.ts 251 passed
npm run typecheck 0 errors
eslint on the changed serve files (--max-warnings 0) clean

The newly-extracted route groups (daemon-status, session, sse-events, workspace-auth/-status/-extensions, permission) have no dedicated unit tests — their behavior is covered by server.test.ts (the 518 integration tests that assemble the app via createServeApp). Those passing after extraction is the primary "behavior unchanged" signal.

2. Refactor invariants — statically verified (base ↔ PR)

  • Compatibility exports preserved. The 10 symbols that moved out of server.ts are all re-exported from it (createDefaultFsAuditEmit, resolveBridgeFsFactory, PromptDeadlineExceededError, resolvePromptDeadlineMs, detectFromLoopback, InvalidCursorError, listWorkspaceSessionsForResponse, ListWorkspaceSessionsOptions, ListWorkspaceSessionsResult, getActiveSseCount) — so existing ./server.js importers (run-qwen-serve, ACP HTTP dispatch, tests) keep working.
  • Middleware order identical. The app.use(...) chain in createServeApp is byte-identical base↔PR (Origin strip → CORS → host allowlist → access log → bearer auth → rate limit → express.json → JSON error mapper → daemon telemetry → error handler), matching the design doc's 14-step contract.
  • Route set identical. A multiline-aware extraction across the whole serve/ tree finds exactly 89 (method, path) routes on both base and PR — identical set, 0 lost, 0 added. Routes only moved between files.

3. Real daemon e2e (tmux) — every split module answers

qwen serve --port 41700 --no-web on loopback, probed with curl:

Module Route Status
health-demo GET /health, GET /demo 200, 200
daemon-status GET /daemon/status 200
(inline) GET /capabilities 200
workspace-status GET /workspace/preflight, /hooks, /env 200
workspace-extensions GET /workspace/extensions, /skills, /tools, /mcp 200
workspace-auth GET /workspace/auth/status, /auth/providers 200
session-list GET /workspace/:id/sessions 200
error-response GET /nonexistent → 404 page; GET /session/nope/stats404 {"error":"No session with id \"nope\""} 404
session POST /session (real session created); workspace-mismatch → 400 200 / 400
permission POST /permission/:id bad vote → 400 error taxonomy 400

4. Base-vs-PR HTTP A/B — 18/18 routes byte-identical

Same probe script run against a base-commit daemon (merge-base f6c8ee1e8, built separately on :41800) and the PR daemon, both fresh, identical probe order, responses normalized for provably non-deterministic fields (UUIDs, timestamps, pid/RSS/heap, timing, async MCP discoveryState, worktree path):

SUMMARY: same=18  diff=0

Every route returns an identical normalized body and identical status code on both binaries. (An earlier uncontrolled run showed 5 "diffs" that were all noise — memory RSS, preheat.durationMs, async discovery state, the two different worktree paths, and a probe-ordering session-count delta — which vanish under controlled ordering + normalization.)

5. SSE contract preserved

GET /session/:id/events on a live session opens with Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, and the initial retry: 3000 frame — identical on base and PR. Deeper streaming (replay ring, writer-idle eviction, client-disconnect abort) is exercised by the passing server.test.ts SSE tests and acp-http/sse-stream.test.ts.

Notes (non-blocking)

  • The diff is large (+5594 / −4985) but mechanical; the extracted route modules follow the pre-existing registerXRoutes(app, deps) pattern already used by workspace-file-read.ts etc., so blast radius is contained.
  • The route-set diff compares the set of (method, path) pairs; intra-group ordering isn't asserted statically, but Express matches distinct literal paths order-independently and the 518 integration tests would catch any route shadowing — and the live A/B confirms identical responses.
  • A design doc (.qwen/design/serve-server-split.md) is committed alongside the code; harmless process artifact.

Method / environment

macOS (darwin), Node v22.22.2. Two isolated worktrees built independently: PR head e57561b20 and merge-base f6c8ee1e8. Real binary = packages/cli/dist/index.js; both daemons run on loopback (auth-free) against the same throwaway workspace, probed with the same script, diffed with field normalization.

🇨🇳 中文版(完整对应)

✅ 本地真实 daemon 验证 —— PR #5809

结论:纯结构重构,行为端到端保持不变 —— 可以合并。

我在隔离 worktree 中基于 PR head(e57561b20)构建了真实 qwen 二进制,跑了 PR 声称的测试套件,静态核验了重构不变量(导出 / middleware 顺序 / route 集合),然后在 tmux 里启动真实的 qwen serve daemon 确认每个拆分出的路由仍能响应——并对一个 base 提交的 daemon 做了 HTTP 响应逐字节 A/B

1. 构建 & 检查(PR 声称的命令)

命令 结果
npm ci && npm run build exit 0
vitest run src/serve/server.test.ts(低并发) 518 通过
vitest run src/serve/acp-http/*.test.ts src/serve/routes/*.test.ts 251 通过
npm run typecheck 0 错误
对改动的 serve 文件跑 eslint--max-warnings 0 干净

新抽取的 route 组(daemon-status、session、sse-events、workspace-auth/-status/-extensions、permission)没有独立单测——它们的行为由 server.test.ts(经 createServeApp 装配整个 app 的 518 个集成测试)覆盖。抽取后这些测试仍全过,是"行为不变"的首要信号。

2. 重构不变量 —— 静态核验(base ↔ PR)

  • 兼容导出保留。server.ts 移出的 10 个符号全部从它 re-export(createDefaultFsAuditEmitresolveBridgeFsFactoryPromptDeadlineExceededErrorresolvePromptDeadlineMsdetectFromLoopbackInvalidCursorErrorlistWorkspaceSessionsForResponseListWorkspaceSessionsOptionsListWorkspaceSessionsResultgetActiveSseCount)——所以现有从 ./server.js import 的代码(run-qwen-serve、ACP HTTP dispatch、测试)继续可用。
  • middleware 顺序一致。 createServeApp 里的 app.use(...) 链在 base↔PR 之间逐字节一致(Origin 剥离 → CORS → host allowlist → 访问日志 → bearer auth → rate limit → express.json → JSON 错误映射 → daemon telemetry → 错误处理),符合设计文档的 14 步契约。
  • route 集合一致。 对整个 serve/ 树做多行感知提取,base 和 PR 都恰好 89 条 (method, path) 路由——集合完全一致,0 丢失、0 新增。 路由只是在文件间移动。

3. 真实 daemon e2e(tmux)—— 每个拆分模块都响应

loopback 上 qwen serve --port 41700 --no-web,用 curl 探测:

模块 路由 状态码
health-demo GET /healthGET /demo 200、200
daemon-status GET /daemon/status 200
(内联) GET /capabilities 200
workspace-status GET /workspace/preflight/hooks/env 200
workspace-extensions GET /workspace/extensions/skills/tools/mcp 200
workspace-auth GET /workspace/auth/status/auth/providers 200
session-list GET /workspace/:id/sessions 200
error-response GET /nonexistent → 404 页面;GET /session/nope/stats404 {"error":"No session with id \"nope\""} 404
session POST /session(真实创建 session);workspace 不匹配 → 400 200 / 400
permission POST /permission/:id 非法 vote → 400 错误分类 400

4. Base vs PR 的 HTTP A/B —— 18/18 路由逐字节一致

同一份探测脚本分别打到 base 提交的 daemon(merge-base f6c8ee1e8,单独构建在 :41800)和 PR daemon,两者全新启动、探测顺序相同、响应对可证非确定字段(UUID、时间戳、pid/RSS/heap、计时、异步 MCP discoveryState、worktree 路径)做归一化:

SUMMARY: same=18  diff=0

每个路由在两个二进制上都返回一致的归一化 body 一致的状态码。(更早一次未受控的运行出现 5 个"diff",全是噪音——内存 RSS、preheat.durationMs、异步发现状态、两个不同的 worktree 路径、以及探测顺序导致的 session 计数差——在受控顺序 + 归一化后全部消失。)

5. SSE 契约保留

对一个 live session 打 GET /session/:id/events,以 Content-Type: text/event-streamCache-Control: no-cache, no-transformConnection: keep-alive 开流,首帧为 retry: 3000——base 和 PR 完全一致。更深的流式行为(重放 ring、writer-idle 驱逐、client 断连 abort)由通过的 server.test.ts SSE 测试和 acp-http/sse-stream.test.ts 覆盖。

说明(不阻塞)

  • diff 很大(+5594 / −4985)但属机械搬运;抽取的 route 模块沿用 workspace-file-read.ts 等已有的 registerXRoutes(app, deps) 模式,影响面受控。
  • route 集合 diff 比较的是 (method, path)集合;组内顺序未做静态断言,但 Express 对不同字面量路径的匹配与顺序无关,且 518 个集成测试能抓到任何路由遮蔽——live A/B 也确认响应一致。
  • 一个设计文档(.qwen/design/serve-server-split.md)随代码提交;无害的流程产物。

方法 / 环境

macOS (darwin),Node v22.22.2。两个隔离 worktree 各自独立构建:PR head e57561b20 和 merge-base f6c8ee1e8。真实二进制 = packages/cli/dist/index.js;两个 daemon 都在 loopback(免 auth)上对同一个临时 workspace 运行,用同一脚本探测,diff 时做字段归一化。

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

@qwen-code-ci-bot qwen-code-ci-bot 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.

No review findings. Downgraded from Approve to Comment: CI still running.

Clean structural refactor — behavioral equivalence verified across middleware ordering, error handling, auth gates, SSE lifecycle, and all 769 tests (518 server + 251 route/integration). Build, typecheck, and ESLint all pass. Nine review agents (correctness, security, code quality, performance, test coverage, three adversarial personas, build/test verification) found no high-confidence issues. The extraction faithfully preserves the original behavior.

— qwen3.7-max via Qwen Code /review

wenshao
wenshao previously approved these changes Jun 24, 2026
@wenshao

wenshao commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
yiliang114
yiliang114 previously approved these changes Jun 26, 2026

@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.

Reviewed as a structural-only split — spot-checked the spots where this kind of extraction usually breaks:

  • The consolidated safeLogValue / CLIENT_ID_RE / MAX_CLIENT_ID_LENGTH in request-helpers.ts match the originals exactly, so the de-duplication removes the old "keep in sync" hazard without drift.
  • Every register*Routes(app, …) is wired, and health-demo is still mounted exactly once via the pre/post-auth branch — no dropped or double-registered routes.
  • No extracted route file adds a global app.use, so the middleware stack composed in server.ts is unchanged.

Ubuntu unit tests (server + routes) are green. LGTM.

@qwen-code-ci-bot qwen-code-ci-bot 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.

⚠️ Downgraded from Approve to Comment: CI still running.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/serve/server/telemetry.ts
Comment thread packages/cli/src/serve/server.ts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot 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.

No review findings. Downgraded from Approve to Comment: CI still running.

Clean structural refactor — behavioral equivalence verified across middleware ordering, error handling, auth gates, SSE lifecycle, and all 845 tests (529 server + 316 route). Typecheck and lint clean. 9 parallel review agents (correctness, security, code quality, performance, test coverage, attacker/oncall/maintainer audit) + deterministic analysis + reverse audit confirmed no new issues introduced by the split.

— qwen3.7-max via Qwen Code /review

@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

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot 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.

No review findings. Downgraded from Approve to Comment: CI still running.

Clean structural refactor — behavioral equivalence verified across middleware ordering, error handling, auth gates, SSE lifecycle, and all 845 tests (529 server + 316 route). Typecheck and lint clean.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot 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.

No review findings. Downgraded from Approve to Comment: CI still running.

Clean structural refactor — behavioral equivalence verified across middleware ordering, error handling, auth gates, SSE lifecycle, and all 845 tests (529 server + 316 route). Typecheck and eslint clean. 9 parallel review agents + deterministic analysis + reverse audit found no issues.

— qwen3.7-max via Qwen Code /review

@wenshao
wenshao added this pull request to the merge queue Jun 26, 2026
Merged via the queue into QwenLM:main with commit 2199382 Jun 26, 2026
48 checks passed
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request Jun 27, 2026
Round-6 review (qwen-code-ci-bot), all additive / no behavioural side effects:

- [Critical] Unbounded SSE buffer in the new AcpHttpTransport session-stream
  parser → OOM (tab crash for browser consumers). Add a 16 MiB cap mirroring
  parseSseStream's MAX_BUF_CHARS, and reuse parseSseStream's CRLF-aware
  `consumeFrames` splitter (now exported) instead of an inline `\n\n` scan —
  closing the CRLF, multi-line `data:` join, and trailing-CR gaps in one go.
- Deferred-flush ordering race: the pump's post-loop safety flushDeferred()
  now runs only on a non-aborted exit. An abort means the stream was
  detached/reclaimed; flushing there could drain the deferred reply onto a
  reclaiming stream ahead of its own replay (reintroducing the out-of-order
  delivery the deferral prevents). On error the frames stay buffered for the
  next attach — never lost.
- Grace reclaim now logs (detach + grace-expiry already did) so the reconnect
  trail is complete for operators.
- sse-last-event-id doc: corrected the "shared by REST and ACP" claim — after
  the QwenLM#5809 serve-route split REST keeps its own copy; unifying them would
  touch REST, so it's deferred (this PR keeps REST untouched).

Thread on a full deferred-flush integration test: the ordering invariant is
already locked at the unit layer (flushBufferedSessionFrames defer test +
gap-delivery test); a full-HTTP timing test against the FakeBridge would be
flake-prone, so it's intentionally not added.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
pull Bot pushed a commit to bhardwajRahul/qwen-code that referenced this pull request Jun 30, 2026
…-in SDK transports export (QwenLM#5852)

* fix(daemon): resume /acp session stream via Last-Event-ID (recover mid-turn content)

The `/acp` Streamable-HTTP session event stream was live-only: it emitted no
SSE `id:` sequence and ignored a `Last-Event-ID` reconnect header. When a
control-plane proxy idle-closed the long-lived SSE mid-turn, every content
frame the daemon produced during the gap (`session/update` carrying
agent_thought_chunk / agent_message_chunk) was lost — the turn still settled,
so the UI showed "done" with an empty/truncated body, and only a re-send
recovered it (tracked as §1.8 in the integration notes).

The replay engine already exists and is battle-tested on the REST surface:
EventBus assigns a monotonic per-session `id`, keeps a bounded ring, and
`subscribeEvents({ lastEventId })` replays `id > lastEventId` before live
events flow. This wires the `/acp` transport to it — no eventBus/bridge change.

- transport-stream / sse-stream / ws-stream: `send(message, id?)`. SSE emits an
  `id:` line when `id` is present (mirrors REST `formatSseFrame`); WS ignores it
  (stateful, no replay).
- connection-registry: `sendSession(…, id?)` threads the cursor; the pre-attach
  session buffer stores `{ frame, id? }` so a buffered frame keeps its `id:`.
- dispatch: `translateEvent` passes `event.id` for bus events; `pumpSessionEvents`
  forwards `lastEventId` to `subscribeEvents`.
- index: the `GET /acp` session branch reads `Last-Event-ID` (strict
  decimal-only parse, same rule as REST) and passes it to the pump.

Bus-originated frames (session/update, request_permission, daemon notifies)
carry an `id:`; JSON-RPC responses and synthetic terminal frames do not, so
they don't burn a slot in the resume sequence. Backward compatible: clients
that send no `Last-Event-ID` get live-only behaviour as before, and `id:`
lines are inert for clients that ignore them.

Design: docs/design/daemon-acp-http/sse-resumable-stream.md

* fix(daemon): make /acp resume actually engage — session-stream grace/reclaim + replay guards

Addresses three review Criticals on the §1.8 plumbing: on its own the
`id:`/`Last-Event-ID` wiring never fired in the real close-then-reconnect flow,
and once it does fire two replay-correctness gaps become reachable.

1. Session-stream grace/reclaim (the core fix). A transport-level session-stream
   close used to run the FULL `closeSessionStream` teardown — removing
   ownership, aborting the in-flight prompt, detaching the bridge client. In the
   real EventSource/proxy order (old socket closes first, then reconnect) that
   meant the reconnect carrying `Last-Event-ID` was rejected 403 before the
   cursor was read, and the prompt was already aborted — so replay had nothing
   to resume. Now a transport close DETACHES (`detachSessionStream`): it stops
   only the stream + subscription and keeps the binding, ownership, prompt, and
   bridge-client alive for a grace window (`SESSION_GRACE_MS`, mirrors
   `CONN_GRACE_MS`). A reconnect within the window reclaims (clears the timer);
   otherwise the grace timer runs the full teardown, bounding runaway cost. Full
   teardown stays immediate for explicit `session/close` and connection destroy.
   The GET handler branches on `stream.isClosed` (transport close → grace;
   pump-ended-while-open → full close).

2. No double-delivery (buffer ↔ ring overlap). `attachSessionStream` records the
   max bus id flushed from the pre-attach buffer; the GET handler advances the
   replay cursor to `max(Last-Event-ID, lastFlushedEventId)` so the ring replay
   doesn't re-emit an already-flushed frame.

3. Idempotent `permission_request` under replay. `translateEvent` reuses the
   existing `conn.pending` entry for a `bridgeRequestId` (re-sends the same
   outbound id) instead of minting a second id+entry — no orphan pending, no
   duplicate prompt on a ring-replayed permission.

Also: extract `parseLastEventId` to a shared `serve/sse-last-event-id.ts` used
by both REST and `/acp` (no drift; logs the rejected value); log `lastEventId`
in the pump error.

Tests: real close-then-reconnect order (200 not 403 + prompt not aborted);
overflow Last-Event-ID; replayed permission reuses pending id; registry
grace/reclaim + buffer-flush-preserves-id. Full acp-http suite green (216).

* feat(sdk): expose ACP transports via opt-in ./daemon/transports subpath

The resumable ACP-over-HTTP transport (AcpHttpTransport, native
supportsReplay + Last-Event-ID) and the negotiateTransport factory were
reachable only from source paths inside the monorepo — the published
`@qwen-code/sdk/daemon` barrel intentionally omits them to keep its
budget-checked browser bundle lean, so external consumers (agent-web)
had no import path short of forking.

Add a separate opt-in subpath `@qwen-code/sdk/daemon/transports` that
ships AcpHttpTransport / AcpWsTransport / AutoReconnectTransport /
RestSseTransport / negotiateTransport as their own browser+node bundle.
The default `./daemon` barrel and its byte budget are unchanged, so
REST-only consumers stay tree-shaken and pay nothing for the transports.

Also add a `fetchFn` option to NegotiateTransportOptions so callers can
inject auth/proxy/test fetch instead of the hardcoded global.

- build.js: emit dist/daemon/transports.{js,cjs}; reuse the node-builtin
  guard for the new browser bundle (no size budget — it legitimately
  ships the transports) while keeping the default barrel's budget check.
- daemon/index.ts: update the rationale comment to point at the subpath.
- daemon-transports-surface.test.ts: lock the runtime + type surface and
  the package.json exports entry.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): resume cursor must not skip in-flight-lost frames

Round-3 review (qwen-code-ci-bot) flagged a silent-frame-loss Critical in
the §1.8 resume path I added: `resumeCursor = max(Last-Event-ID,
lastFlushedEventId)` advances the ring-replay cursor past the buffer, but a
frame sent to the now-dead socket yet never received by the client has a bus
id BELOW the buffer's ids and ABOVE the client's cursor — so the max() skips
it and the ring replay never re-emits it. Exactly the proxy idle-close
mid-turn frame §1.8 is meant to recover.

Fix without trading loss for duplicates: a buffered bus event is ALSO in the
EventBus ring (it was published there to get its id), so the ring replay
started at the client's cursor is the single delivery path for every bus
event after the cursor. `attachSessionStream` now takes the resume cursor and,
when resuming, does NOT flush id-bearing buffered frames — the ring owns them,
delivering each exactly once including the in-flight-lost frame. Id-less
frames (JSON-RPC replies via `replySession`, not ring events) are still
flushed — their only delivery path. The GET handler sets
`resumeCursor = lastEventId` verbatim; `lastFlushedEventId` is removed.

Also from the same review:
- sse-last-event-id `safeLogValue`: strip ALL C0 control chars + DEL (not just
  CR/LF) so a crafted `Last-Event-ID` can't smuggle ANSI ESC / null bytes onto
  an operator's terminal via stderr.
- ws-stream: regression test asserting `send(msg, id)` keeps the WS wire frame
  bare JSON (no SSE `id:` framing leak).
- connection-registry: resume-path test (id-bearing frames skipped, id-less
  reply still flushed); design doc updated.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(daemon): inline resumeCursor alias to lastEventId

Review nit (yiliang114): after the prior commit dropped the `max()` logic,
`resumeCursor` is a pure alias for `lastEventId`. Use `lastEventId` directly in
the `pumpSessionEvents` call and the error log; drop the alias.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(daemon): refresh stale resume comments + add gap-delivery test

Round-4 review (qwen-code-ci-bot, against the now-corrected resume model):

- connection-registry: the attachSessionStream CONTRACT comment still cited a
  `promptAbort?.abort()` call in the index.ts onClose handler that an earlier
  commit removed. Rewrite it to describe the current model — each stream's pump
  has its own abort controller and teardown is identity-guarded in
  `onPumpSettled`, so installing the new stream first makes the old stream
  settle into detach-with-grace rather than tearing down the in-flight prompt.
- dispatch: the stream_error frame comment ("no bus id, so no SSE id: line")
  contradicted the code passing `event.id`. Make it truthful: pass the cursor
  through if present; a synthetic terminal frame has no id so none is written.
- connection-registry.test: add the explicit detach → produce gap events →
  reattach → flush-exactly-once test (the PR's core value prop at the registry
  layer), incl. a second reattach asserting the buffer drained.

The two Criticals in the same review referenced `resumeCursor` /
`lastFlushedEventId` / `Math.max`, all removed in prior commits — obsolete
against current code (answered + resolved on the threads).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): preserve stream order on resume + harden grace/permission paths

Round-5 review (wenshao + qwen-code-ci-bot):

- [Critical, wenshao] Out-of-order completion on resume. attachSessionStream
  flushed id-less buffered JSON-RPC replies (e.g. a session/prompt result that
  landed during the detach gap) immediately — ahead of the ring replay that
  redelivers the content chunks preceding them, so a client could see "prompt
  complete" before the body (the truncated-body failure §1.8 fixes). Now on
  resume those id-less frames are DEFERRED in the buffer; the event pump
  releases them via flushBufferedSessionFrames once the replay boundary
  (replay_complete / state_resync_required) passes, preserving original order.
  Fresh connects (no cursor, no replay) still flush the whole buffer in order.

- [Critical, ci-bot] Permission auto-denied during the reconnect grace window:
  a permission_request arriving while binding.stream is detached cancel-denies,
  so a client reconnecting within grace can't vote. The structural fix (defer
  the vote across grace) belongs with the §1.7 permission-coordination
  follow-up; here, log an operator breadcrumb when it fires during grace, and
  document the synchronous-translateEvent INVARIANT the direct binding.stream
  .send relies on.

- [ci-bot] Stale-stream detach is now tested (reclaim installs s2; a late s1
  close is a no-op — no teardown, no grace re-arm). Grace-expiry teardown now
  logs a breadcrumb so a vanished session is distinguishable from explicit
  close. TS4111: bracket-access the index-signature exports entry in the SDK
  surface test. transports browser bundle now has a size budget
  (MAX_TRANSPORTS_BROWSER_BUNDLE_BYTES = 48KB; current ~29KB).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): route session-scoped /acp responses so prompts don't hang

[Critical, wenshao] The published AcpHttpTransport could hang real
session/prompt + config requests. Its subscribeEvents() opened REST
GET /session/:id/events and only sendRequest's connection-scoped stream
resolved responses — but the daemon's replySession() routes session-scoped
JSON-RPC replies onto the session-scoped /acp stream, which the transport
never read. So a session/prompt reply was never observed → the pending
request never settled.

Switch subscribeEvents to the session-scoped /acp stream (GET /acp +
Acp-Session-Id) — the resumable §1.8 stream the daemon puts session replies
on — and dispatch each raw JSON-RPC frame by shape:
- response (id, no method)      → resolve the shared pending map (the fix)
- notification (method, no id)   → DaemonEvent via denormalizeAcpNotification,
                                   stamped with the real bus id from the SSE
                                   `id:` line (the synthetic denormalizer id is
                                   not resume-compatible; supportsReplay=true
                                   now tracks the authoritative cursor)
- session/request_permission     → surfaced as a permission_request event so
                                   consumers still see prompts (responding to
                                   the vote is the §1.7 follow-up)

The connection-scoped stream still carries replies to connection-level
requests (initialize, session/new). Adds 4 subscribeEvents unit tests
(stream selection + headers, notification→event+busId, response consumed-not-
yielded, permission surfaced). Full SDK suite green (1062).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk,daemon): harden /acp SSE parser + grace/flush observability

Round-6 review (qwen-code-ci-bot), all additive / no behavioural side effects:

- [Critical] Unbounded SSE buffer in the new AcpHttpTransport session-stream
  parser → OOM (tab crash for browser consumers). Add a 16 MiB cap mirroring
  parseSseStream's MAX_BUF_CHARS, and reuse parseSseStream's CRLF-aware
  `consumeFrames` splitter (now exported) instead of an inline `\n\n` scan —
  closing the CRLF, multi-line `data:` join, and trailing-CR gaps in one go.
- Deferred-flush ordering race: the pump's post-loop safety flushDeferred()
  now runs only on a non-aborted exit. An abort means the stream was
  detached/reclaimed; flushing there could drain the deferred reply onto a
  reclaiming stream ahead of its own replay (reintroducing the out-of-order
  delivery the deferral prevents). On error the frames stay buffered for the
  next attach — never lost.
- Grace reclaim now logs (detach + grace-expiry already did) so the reconnect
  trail is complete for operators.
- sse-last-event-id doc: corrected the "shared by REST and ACP" claim — after
  the QwenLM#5809 serve-route split REST keeps its own copy; unifying them would
  touch REST, so it's deferred (this PR keeps REST untouched).

Thread on a full deferred-flush integration test: the ordering invariant is
already locked at the unit layer (flushBufferedSessionFrames defer test +
gap-delivery test); a full-HTTP timing test against the FakeBridge would be
flake-prone, so it's intentionally not added.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon,sdk): harden /acp resumable stream from review round 7

Address review-pr findings on the §1.8 resumable stream, all additive /
backward-compatible (REST untouched, no behavioural side effects):

- connection-registry: guard flushBufferedSessionFrames against a closed
  stream so deferred replies stay buffered for the next reconnect instead
  of being dropped onto a dead socket. Keep the synchronous in-order
  enqueue (SseStream serializes via one writeChain) — an await-per-frame
  drain would let a live event interleave between deferred frames and
  reorder the very replies this deferral preserves (W1).
- connection-registry: log at the moment of detach so an operator can
  measure the real disconnect→reconnect gap against the grace window.
- index: route sessionId through logSafe() in the event-pump error log,
  matching every other log line this PR adds (terminal-escape hardening).
- AcpHttpTransport: remove the abort listener in the finally block so a
  long-lived signal reused across reconnects doesn't accumulate listeners.
- AcpHttpTransport: parse the SSE `id:` cursor with the server's strict
  /^\d+$/ + MAX_SAFE_INTEGER rule instead of lenient Number() (rejects
  proxy-mangled hex/exponential/empty cursors).
- AcpHttpTransport: document that an unparseable non-empty data frame is a
  corrupt frame (not a heartbeat); tracing it is a follow-up once the SDK
  grows a logger (the package lint config forbids console).
- tests: add sse-last-event-id.test.ts (parseLastEventId accept/reject +
  safeLogValue control-char stripping/truncation) and a
  flushBufferedSessionFrames closed-stream-retains-buffer case.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon,sdk): round-8 review hardening for /acp resumable stream

All additive / backward-compatible (REST untouched, no behavioural side
effects):

- AcpHttpTransport: attach a no-op catch to abortPromise so an
  already-aborted signal at entry (loop never enters, Promise.race never
  consumes the rejection) can't surface as an unhandled rejection.
- AcpHttpTransport: document that opts.maxQueued does not apply to the
  /acp transport (the session stream is backed by the daemon's
  server-controlled EventBus ring; there is no client-tunable queue to
  forward it to) — intentionally ignored, not silently mis-applied.
- index: run err.message through logSafe() in the event-pump error log
  (CR/LF/ANSI in a bridge error string would otherwise reach stderr raw),
  and add operator breadcrumbs for the previously-silent onPumpSettled
  branches (pump-ended-while-open full close; superseded-stream no-op),
  completing the detach/reclaim/grace trail.
- tests: assert subscribeEvents writes Last-Event-ID on the outbound GET
  when resuming and omits it on a first connect (the resume cursor must
  reach the wire), plus an already-aborted-signal case that would fail on
  an unhandled rejection without the catch above.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): flush deferred /acp replies on replay_complete only

The EventBus emits `state_resync_required` BEFORE the replay frames (the
`epoch_reset` and `ring_evicted` paths both fall through to the replay
loop and still emit `replay_complete` at the end). The pump was releasing
the deferred id-less replies on EITHER boundary, so on a resync-triggering
resume the buffered `session/prompt` result was flushed ahead of the
replayed content chunks — the exact truncated-body reordering §1.8 fixes
(client sees "done" before the body).

Flush on `replay_complete` only. The live-only case (no cursor ⇒ no replay
⇒ no `replay_complete`) is still covered by the pump's post-loop safety
flush. Add an over-the-wire integration test (resume with a reply buffered
during the detach gap, bridge replays resync → content → replay_complete)
asserting the reply lands AFTER the replayed content; verified it fails
against the previous dual-boundary flush. Design doc updated.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): close two §1.8 grace/replay-ordering holes

Both additive / in-scope / no REST change:

- Replay-window reply ordering (connection-registry, dispatch): the
  resumptive-attach deferral only covered id-less replies ALREADY buffered
  from the detach gap. A prompt that finished AFTER the new stream attached
  but BEFORE replay drained went straight out live via `sendSession`,
  overtaking replay frames not yet sent. Add a per-binding `replayPending`
  flag (armed on resumptive attach, cleared on `replay_complete` in
  `flushBufferedSessionFrames`) and route `replySession`'s out-of-band
  replies through a new `sendSessionReply` that defers while it's set.
  In-band pump frames keep using `sendSession`, so the `replay_complete`
  frame itself can't be deferred (which would deadlock the release).

- Connection reaper vs session grace (index, connection-registry): the
  conn-stream-close reaper treated only LIVE session streams as activity,
  so a session detached into its own `SESSION_GRACE_MS` window (stream
  undefined, graceTimer armed) didn't count — the connection could be
  reaped at `CONN_GRACE_MS`, 404-ing the imminent session resume and
  aborting the in-flight prompt early. Add `hasRecoverableSession()` and
  treat a grace-armed session as activity in the reaper guard.

Unit tests for both at the registry layer.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): thread query params into ACP transport route extractors

The exported ACP HTTP/WS transports reduced request URLs to `pathname`
before the route table built JSON-RPC params, so every query parameter
from the REST-style DaemonClient helpers was dropped — e.g.
`readWorkspaceFile('a.ts', { maxBytes: 123 })` (`/file?path=a.ts&maxBytes=123`)
produced `_qwen/file/read` with `params: {}`. Same for `/file/bytes`,
`/stat`, `/list`, `/glob`, and `context-usage?detail=true`.

Pass `parsedUrl.searchParams` into `extractParams` and coerce each query
value to the type the daemon's ACP handlers require — the daemon validates
`maxBytes`/`line`/`limit`/`offset` as real numbers and `detail` as the
boolean `true`, neither of which a raw query string satisfies. Helpers
`strParam`/`numParam`/`boolParam` keep the per-route extractors terse.
`query` is optional so the existing path-only extractors are unaffected.
(`/workspace/voice/transcribe` has no ACP route at all — separate gap,
binary audio doesn't belong on the JSON-RPC transport.)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): route session-stream replies via a background pump (no-subscriber prompt)

The daemon answers POST /session/:id/prompt (and session/cancel,
set_config_option, set_mode, set_model) with 202 and routes the JSON-RPC
result onto the SESSION stream via replySession — not the connection
stream the transport pumps. So a DaemonClient that calls prompt() but
never iterates subscribeEvents had nothing reading that reply, and
sendRequest()'s pending promise never resolved → prompt() hung forever.

For these session-reply methods, sendRequest now opens a reference-counted
background session-reply pump (GET /acp + Acp-Session-Id) that routes
JSON-RPC responses to `pending`, released when the request settles. It's
suppressed when a subscribeEvents consumer is already iterating that
session (tracked via activeSessionSubscriptions) — the daemon's session
stream is single-reader, so a competing GET would detach the consumer's;
in that case the consumer already routes the reply (the W2 fix). The pump
skips notifications and permission requests (method-bearing frames) so a
permission request id can't be mis-routed onto a pending response slot.
All five methods require an owned session, so the pump's GET is always
authorized. Disposed pumps are aborted in dispose().

Verified the new test times out without the pump and passes with it.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): sequence /acp deferred replies by bus watermark + harden grace/reap

Address review on the §1.8 resumable-stream fixes:

- replayPending is now set from the current attach mode every time
  (resume arms, fresh connect clears) so an aborted resume that skipped
  its boundary flush can't strand the flag and buffer every later reply
  forever (MsyIq, MylZ4).
- Deferred out-of-band replies carry a watermark (anchorId = bus head at
  produce time) and release only once the pump delivers through that id,
  via per-event releaseDeferredSessionReplies + endReplayDeferral at
  replay_complete. A result produced during a slow replay no longer jumps
  ahead of tail content still flowing as live events behind the boundary
  (MsyIt). Unanchored fallback replies still release at the boundary.
- Connection reap re-evaluates after a session reclaim grace expires
  (connGraceExpired + onSessionGraceExpired), so a conn blocked from
  reaping by a then-recoverable session no longer lingers to the 30-min
  idle sweep (MsyIs).
- Wrap the grace-timer teardown in try/catch so a throwing detach
  callback can't crash the daemon from a bare setTimeout (MylZ8).
- sse-last-event-id reuses the shared logSafe sanitizer (covers C1 +
  Unicode bidi) instead of a narrower divergent regex (M1isz); refresh
  stale replayPending/flush JSDoc (MselO).

Unit tests cover the replayPending reset, watermark ordering, grace
expiry hook, and grace-timer try/catch.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): scope pending sweeps per stream + reset bus cursor on invalid id

Address review on the ACP HTTP transport:

- Tag each pending request with its routing scope (connection vs a
  sessionId). A connection-stream failure now sweeps only conn-scoped
  pendings, so it can't reject a session/prompt the session stream is
  about to resolve; the session reply pump mirrors this for its own
  scope (MselM).
- An invalid id: line later in an SSE frame resets the bus cursor to
  undefined rather than carrying a stale earlier value into the event
  (MselW).
- Strengthen the W2 response-routing test to register a pending request
  and assert the frame RESOLVES it, not merely that it isn't yielded
  (MylZ-). Add tests for the per-stream sweep partition and the id reset.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): accept `kind`-tagged _qwen/notify envelopes (don't drop resume signals)

The daemon's session-stream translateEvent stamps `_qwen/notify` events
under `kind` (state_resync_required, replay_complete, stream_error,
model_switched, …), but denormalizeAcpNotification read only `type` and
returned undefined for them — so subscribeEvents silently dropped every
such event. During a ring-overflow resume the SDK would never see
state_resync_required and would apply replayed events to stale state.

Read `params['type'] ?? params['kind']` (preferring `type`, so other
producers are unaffected) and add an SDK test feeding a `kind`-tagged
notify through subscribeEvents (M2bvl).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): reject session-scoped pendings when the subscription stream closes

A `session/prompt` reply routed through an active subscribeEvents consumer
(no reply pump is started while a subscription is live) would hang if that
session SSE stream closed before the reply arrived: the connection-stream
catch only sweeps conn-scoped pendings, and subscribeEventsInner's finally
cleaned up the reader but never the pendings.

Sweep session-scoped pendings in that finally too, gated so it only fires
when this is the session's last delivery route (no other active
subscription — the ref-count still includes self here — and no reply pump),
mirroring the reply-pump and connection-stream sweeps. Add a regression
test (M2iHz).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): harden ACP SSE readers + reply-pump handoff + empty query param

Address the latest review wave on the transport:

- pumpConnStream now bounds its unread SSE buffer with the same
  MAX_SSE_BUF_CHARS guard the two session readers already have — the OOM
  vector (a server that never emits a `\n\n` boundary) was open on 1 of 3
  readers — and attaches the no-op abortPromise.catch() crash guard (M3BYQ).
- pumpSessionReplies mirrors subscribeEventsInner's abort handling: named
  listener ref removed in finally (no leak on a clean drain of a reused
  signal) + abortPromise.catch() so a pre-aborted signal can't surface an
  unhandledrejection; and it throws the HTTP status on a non-OK response so
  the failure is diagnosable rather than a silent void return (M3BYT, M3BYY).
- subscribeEvents aborts any existing background reply pump for the session
  before opening the consumer stream. The single-reader session stream
  detaches the pump anyway; aborting it skips its teardown sweep so it can't
  spuriously reject the very `session/prompt` the consumer now delivers (M3BYa).
- numParam treats an empty value (`?maxBytes=`) as absent, not Number('')===0
  (M3BYd).

Tests: empty-numeric-param omission, and the reply-pump abort-on-subscribe
handoff (pump aborted, its pending not rejected).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): log a breadcrumb when the replySession anchor is unavailable

The getSessionLastEventId fallback (deferring a reply unanchored when the
ACP binding briefly outlives the bridge session) was silent. Emit a scoped
stderr breadcrumb so an operator can tell that benign teardown race apart
from an unexpected bridge regression that starts exercising the fallback
(M3BYf).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): validate content-type in pumpSessionReplies before parsing as SSE

pumpSessionReplies fed any 2xx body straight to the SSE frame parser. A
non-SSE response (an HTML error page / a JSON proxy error injected by a
CDN) would be consumed as garbage or hang the pump waiting for `data:`
lines that never arrive — strictly weaker validation than its sibling
subscribeEventsInner, which already guards content-type.

Mirror that guard: between the res.ok check and getReader(), reject a body
that isn't text/event-stream (cancelling it first). Add a test that a
no-subscriber session/prompt whose reply pump GET returns text/html
rejects instead of hanging (M3pAM).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): close reply-pump handoff strand race + scope-guard reply resolution

Address the latest review wave:

- subscribeEvents now removes the aborted reply pump's map entry
  SYNCHRONOUSLY, not just aborting it. Otherwise, if the subscription
  exited before the pump's async `.finally` deleted the entry, BOTH
  stranded-pending guards missed (the consumer sweep saw the entry still
  present and deferred; the pump's sweep skipped on abort) — a live
  session/prompt stayed in `pending` forever. Synchronous removal makes the
  consumer sweep deterministically responsible (M3w6Y).
- Reply resolution (both the session-reply pump and the subscribeEvents
  consumer path) now skips a reply whose pending is scoped to a DIFFERENT
  session — defense-in-depth against a future daemon misroute silently
  cross-delivering across the SDK boundary (M3w6d).
- denormalizeAcpNotification prefers a NON-EMPTY `type`; an empty-string
  `type` no longer wins over a valid `kind` and drops the event (M3w6i).

Tests: reply-pump handoff happy-path (delivers/resolves) + strand case
(rejects, not stranded); empty-`type`→`kind` fallback; the SSE buffer cap
firing; the unanchored-reply hold/release branches; and connGraceExpired
reset on reconnect (M3w6e, M3w6f, M3w6g).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): sweep session pendings in the subscribe wrapper + carry pump error

Two follow-ups on the reply-pump handoff:

- The session-scoped pending sweep moves from subscribeEventsInner's
  read-loop finally to the subscribeEvents WRAPPER finally. The read-loop
  finally only runs once the pump reaches the loop; a fast failure (fetch
  reject / non-OK / wrong content-type, all before the loop) skipped it and
  stranded the pending. The wrapper finally always runs, so it covers the
  fast-fail path too (M4DWq).
- ensureSessionReplyPump captures the pump's error (HTTP 401/404, wrong
  content-type) and rejects swept pendings WITH it instead of a generic
  message, so a caller can tell auth failure from a network drop (M4DWx).

Test: a 401 on the session GET (inner throws before its read loop) still
rejects the in-flight session-scoped pending via the wrapper sweep.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): carry the subscription error into the wrapper sweep + guard tests

Follow-ups on the reply-pump handoff:

- The subscribeEvents wrapper sweep now rejects with the actual cause of the
  subscription's exit (captured from a try/catch around the inner generator)
  instead of a hard-coded generic message. On the fast-fail path (401 /
  wrong content-type thrown before the inner read loop) this wrapper finally
  is the only sweep that fires, so the caller now sees the real failure —
  parity with the reply-pump's pumpError reason (M4W9a).

Tests:
- the fast-fail sweep reason carries the 401 (not a generic message);
- the M3pAM non-SSE rejection asserts the content-type cause reaches the
  caller (proves pumpError propagation) (M4W9g);
- cross-session scope guard, both the consumer and the reply-pump
  resolution paths: a reply on session A's stream must not resolve a pending
  scoped to session B (M4W9e).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): guard onSessionGraceExpired in the grace timer against an uncaught throw

The session grace-expiry setTimeout protected closeSessionStream with a
try/catch but called the owner-supplied onSessionGraceExpired callback
outside it. From a bare setTimeout, an uncaught throw there would crash the
whole daemon — the same hazard the teardown guard exists for. Wrap it in
its own try/catch (separate from teardown, so the conn-reap re-check still
runs even if teardown threw). Add a regression test (M4i9z).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): make conn-stream pump CRLF-aware; align replay opt-in doc

The connection-scoped SSE reply pump split frames with an LF-only
`buf.indexOf('\n\n')`. A server or proxy emitting `\r\n\r\n` frame
separators produces no `\n\n` substring, so the scan never found a
boundary: the unread buffer grew to the OOM cap and the pump threw,
leaving every connection-scoped JSON-RPC reply unresolved. Reuse the
shared CRLF-aware `consumeFrames` splitter (and strip a trailing CR
per data line) so the conn pump frames exactly like the session
readers. Add a regression test that delivers a conn-scoped reply over
`\r\n\r\n` and asserts it resolves.

Also update the design doc: the in-repo SDK `AcpHttpTransport` opts in
to replay in this PR (`supportsReplay = true` + resends Last-Event-ID),
so the backward-compat note no longer reads as "keeps false until it
opts in". Only the external agent-web transport flip stays deferred
(already listed under Out of scope).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon,sdk): log replay-deferral arm; document session-reply routing invariant

Add a stderr breadcrumb when a resume arms `replayPending`: while armed,
`sendSessionReply` defers every out-of-band reply until the pump delivers
`replay_complete`. If that sentinel never arrives (a dropped frame or a
pump error), the replies stay buffered indefinitely with no other trace —
the log gives operators a starting point. Silent on a fresh connect (no
deferral). Covered by a new test.

Also strengthen the `SESSION_STREAM_REPLY_METHODS` doc comment: name the
authoritative daemon call sites (dispatch.ts), spell out the hang failure
mode if the set drifts, and record a build-time grep / shared-constant
enforcement as a follow-up (a cross-package invariant the SDK can't type-check).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): use bracket notation for _meta index-signature access (TS4111)

`extractParams` returns `Record<string, unknown>`, so dot access to
`params._meta` violates `noPropertyAccessFromIndexSignature` (set in the
root tsconfig). The esbuild bundle path doesn't typecheck, so CI's build
stayed green, but strict `tsc --noEmit` reports 6 × TS4111 at these sites
(added with the query-param routing change). Switch all six to
`params['_meta']`. Purely syntactic — runtime behavior is unchanged.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): don't silently hang conn-scoped requests on a failed conn stream

`pumpConnStream` swallowed two failure paths: a non-2xx / no-body `GET
/acp` did a bare `return`, and read-loop errors were caught and dropped.
Either way the pump promise RESOLVED, so `openConnStream`'s `.catch`
never ran — connection-scoped JSON-RPC pendings stayed in the map
forever, and `connStreamAbort` was never cleared, so `ensureConnStream`
saw it non-null and never reopened the stream (every later 202 request
hung with no pump to deliver its reply).

- A non-2xx / missing-body response now throws (HTTP status in the
  message) so the catch sweep rejects the conn-scoped pendings.
- The read-loop catch rethrows real errors and only swallows an
  intentional abort (dispose / reconnect, which owns its own cleanup).
- `openConnStream` clears `connStreamAbort` in a `.finally` (guarded on
  controller identity) so the stream reopens on the next request after
  ANY settle — clean close, error, or abort.

Regression test: a 500 `GET /acp` rejects the conn-scoped pending (leaves
session-scoped ones for their own stream) and the next request reopens.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon,sdk): conn-stream error propagation, listener cleanup, buffer eviction, param parsing

Address review findings on the resumable /acp stream and exported SDK
transports — all additive/backward-compatible, REST untouched:

- openConnStream: reject connection-scoped pendings with the pump's REAL
  error (HTTP 401/503, network drop) instead of a generic message, mirroring
  ensureSessionReplyPump.
- pumpConnStream: keep the abort listener in a named ref and remove it in
  finally so a long-lived signal reused across reconnects doesn't accumulate
  listeners (mirrors the session readers).
- sendRequest: remove the abort listener on the happy path (the `{ once: true }`
  listener self-removes only when the signal fires), preventing per-call
  listener buildup on a shared caller signal.
- pushCapped: under a content flood, evict a REPLAYABLE id-bearing frame (the
  ring redelivers it) before an irreplaceable id-less deferred reply — dropping
  the latter would hang the session/prompt caller — and log the dropped id.
- acpRouteTable.boolParam: treat a present-but-empty value (`?detail=`) as
  absent, matching numParam, so `{ detail: false }` isn't forwarded for an
  unset param.
- connection-registry resume flush: hoist the `splice(0)` snapshot into a named
  local to make the re-entrant copy-semantics invariant visible.

Tests: boolParam empty-value omission; pre-attach buffer keeps the id-less
reply under a 400-frame content flood. Document two exported-transport
limitations (permission voting; session RPC awaited inside the subscribeEvents
loop) as §1.7-adjacent follow-ups in the design doc.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): never evict an irreplaceable id-less reply from the pre-attach buffer

The previous pushCapped change preferred evicting id-bearing (ring-replayable)
frames, but left a degenerate hole: when the buffer fills with ONLY id-less
deferred replies (no id-bearing entry exists), findIndex returned -1, dropIndex
fell back to 0, and the oldest deferred JSON-RPC reply was evicted — silently
hanging its session/prompt caller, the exact failure the guard exists to
prevent (wenshao).

Fix: when there is no replayable id-bearing frame to evict, do NOT drop —
append and let the id-less replies exceed the soft cap. The cap is a memory
bound against a CONTENT flood (id-bearing frames); id-less replies are bounded
by the number of in-flight session RPCs the client actually issued
(client-controlled, tiny), so they can't run away in practice. Log once when
over the soft cap. The connection buffer (no id accessor) keeps its FIFO
eviction unchanged.

Test: 300 all-id-less replies buffered past the 256 cap are all delivered on
reconnect, none evicted.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): hard ceiling + transition-only logging for the id-less reply buffer

Follow-ups on the prior id-less-eviction fix (wenshao):

- Defense-in-depth HARD cap. The soft-cap path never drops id-less replies,
  relying on "id-less replies are RPC-bounded" — true today but enforced only
  by convention. Add HARD_BUFFERED_FRAMES_CAP (4× soft = 1024): past it, drop
  the oldest id-less reply and log loudly, so a future non-RPC-bounded producer
  or a buggy client can't grow the daemon heap without limit.
- Log at the soft-cap transition only (buf.length === MAX_BUFFERED_FRAMES), not
  on every over-cap push — the comment said "once" but it logged linearly with
  over-cap depth (~44 lines for 300 entries).

Tests: assert the soft-cap warning fires exactly once for a 300-entry overflow;
new test that 1100 id-less replies are bounded at the 1024 hard cap (oldest
dropped, newest kept, loud breach log emitted).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon,sdk): release all deferred replies when replay evicted frames; guard conn pump against session-scoped pending

When ring replay overflows and emits state_resync_required, the watermark
anchor guarantee is void (the anchored frame may have been evicted), so
hold-until-watermark could freeze deferred session replies indefinitely.
Track eviction through the pump loop and flush ALL buffered session frames
at replay_complete in that case instead of waiting on the watermark.

Also harden the SDK conn-stream pump: never resolve a session-scoped pending
entry from the connection stream (scope guard), and document the fresh-attach
(non-resumptive) caveat for ensureSessionReplyPump.

Tests: add FakeBridge.getSessionLastEventId so integration replySession no
longer throws (anchorId now reachable); cover the eviction cascade-release
path, the conn-stream session-scope guard, and shared reply-pump ref-counting.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(daemon): flush deferred replies on mid-replay iterator error; cover anchored watermark e2e

On an iterator error mid-replay the catch path re-throws, which drives
onPumpSettled; while the session stream is still open that takes the
closeSessionStream branch (full teardown, not a detach-with-grace), so any
still-deferred session replies in the binding buffer were dropped rather than
preserved. Flush them in the catch before signalling stream_error — same
safety flush as the happy-path completion (the iterator has terminated, so no
content frame can still race ahead of them). Correct the now-inaccurate
happy-path comment that claimed error-path frames stay buffered.

Add an end-to-end transport test for the anchored watermark path: with a real
getSessionLastEventId, a deferred reply is held through pre-watermark content
and released ON its anchor mid-replay, before replay_complete — distinguishing
the watermark release from the unanchored release-at-boundary path.

Document two deferrals in the design doc: response-replay idempotency for an
already-resolved permission (a conformant client dedupes on _meta.requestId;
full re-send belongs with the permission-coordination follow-up) and an
automated guard for the SESSION_STREAM_REPLY_METHODS drift.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(daemon,sdk): log replay effectiveness; de-shadow pump sweep; document resume/permission edges

Add an operator breadcrumb at replay completion (resumed-from cursor, delivery
high-water mark, bus replayed count, eviction flag) so 'did resume recover the
gap?' is answerable from server logs.

Rename the reply-pump sweep loop variable so it no longer shadows the outer
pump-map entry (unrelated types).

Clarify why the resume path drops id-bearing buffered frames (the event pump is
aborted on detach, so only id-less out-of-band replies accumulate during the
gap; ring replay owns id-bearing recovery and eviction is signalled via
state_resync_required). Document two opt-in-transport edges as
permission-coordination follow-ups: the no-subscriber reply pump's GET stream
causing an agent permission_request to be routed to the pump and dropped, and
why an automated SESSION_STREAM_REPLY_METHODS drift guard needs dataflow (the
prompt reply is decoupled from its case block).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category/development Development experience daemon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants