fix(daemon): Reject stale prompt client admission#5784
Conversation
Return invalid prompt client ids as synchronous admission failures so daemon clients do not wait forever after a stale client id is rejected. Co-authored-by: Qwen-Coder <[email protected]>
|
Thanks for the PR! Template looks good ✓ — all required sections present including bilingual description. On direction: this solves a real problem — stale client IDs after daemon restarts cause accepted prompts to silently fail, leaving clients stuck waiting for events that never arrive. The fix is squarely within the daemon reliability work the project has been doing (session management, prompt admission contracts). Aligned. On approach: the scope is tight and well-contained. Three production files changed with a total of ~10 lines of logic, plus three test files adding/updating coverage across bridge, daemon route, and SDK. The core insight is simple: One observation: the PR also adds a Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ — 所有必需章节齐全,包括双语说明。 方向:这解决了一个真实问题 —— daemon 重启后 stale client ID 会导致已接受的 prompt 静默失败,客户端一直等待永远不会到达的事件。修复属于项目一直在做的 daemon 可靠性工作范围(session 管理、prompt admission 契约)。对齐。 方案:范围紧凑且克制。3 个生产文件共约 10 行逻辑变更,加上 3 个测试文件在 bridge、daemon 路由和 SDK 层面增加/更新覆盖。核心洞察很简单: 一个观察:PR 还在路由处理器中为 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
Code ReviewIndependent proposal before reading the diff: the bug is that The PR's approach matches this exactly. No simpler path missed. Review findings:
Reuse check: no new utility code or abstractions introduced. The PR reuses existing error types ( Test ResultsAll existing tests pass, plus 7 new/updated test assertions across 3 packages:
Before/AfterBefore (installed build — curl against running daemon)Created a session, then sent a prompt with a stale client ID: Bug confirmed: stale client ID accepted with After (PR fix — vitest evidence, tmux capture-pane)
The focused tests verify:
— Qwen Code · qwen3.7-max |
|
Stepping back to think about this as a whole. My independent proposal for the fix was identical to what the PR does: remove the Does it solve something users care about? Yes. The before-test curl output clearly shows the bug: a stale client ID gets The code is straightforward — 10 lines of production logic change, no abstractions, no drive-by refactors. The Every change in the diff is needed for the stated goal. The bridge test updates ( If I had to maintain this in six months, I'd thank the author — the change is minimal, well-tested, and the motivation is clearly documented. Approving. ✅ 中文说明退后一步整体思考。 我对这个修复的独立提案与 PR 的做法完全一致:去掉 是否解决了用户关心的问题?是的。before 测试的 curl 输出清楚展示了 bug:stale client ID 得到 代码直截了当 —— 10 行生产逻辑变更,没有抽象,没有顺手重构。 diff 中的每个改动都是为了实现声明的目标。bridge 测试更新( 如果六个月后要我维护这段代码,我会感谢作者 —— 改动最小、测试充分、动机文档清晰。 批准 ✅ — Qwen Code · qwen3.7-max |
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.
[Suggestion] The synchronous InvalidClientIdError throw from sendPrompt now routes through the catch block in server.ts:3428, which logs PromptQueueFullError at warn level but is silent for InvalidClientIdError. The old (buggy) async path accidentally logged this via the .then() error handler. The new correct path eliminates that log as a side effect — a stale client hammering the daemon with invalid client IDs produces zero daemon-side signal. Consider adding a parallel log:
if (daemonLog && err instanceof InvalidClientIdError) {
daemonLog.warn('prompt admission rejected: invalid client id', {
sessionId,
...(clientId !== undefined ? { clientId } : {}),
});
}[Suggestion] The fakeBridge.sendPrompt in server.test.ts:1087 mirrors the real bridge's non-async admission contract but lacks a comment documenting it. If a future maintainer adds async to this method, all synchronous admission throws silently become rejected promises and the HTTP route returns 202 for errors that should be 400/503 — with a confusing test failure as the only signal. Consider adding a comment mirroring the real bridge's contract at bridge.ts:2805-2806.
— qwen3.7-max via Qwen Code /review
Log invalid prompt client admission rejections and document the fake bridge's synchronous admission contract. Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No issues found. Downgraded from Approve to Comment: CI still running.
Clean, well-scoped bug fix. The synchronous-throw contract for sendPrompt is correctly implemented — both call sites (server.ts and dispatch.ts) handle the new InvalidClientIdError throw, the prompt slot accounting is verified by activePromptCount assertions, and the SDK tests cover both blocking and non-blocking prompt paths. LGTM ✅
— qwen3.7-max via Qwen Code /review
✅ Maintainer local verification — build, 981 unit tests, an independent real-HTTP e2e, and a mutation testVerified PR #5784 at head Verdict: safe to merge. Clean, focused fix; build + typecheck exit 0; all touched suites pass (981 tests); my independent e2e reproduces the stuck-client bug and confirms the fix; a mutation test proves the change is guarded. CI is already green on macOS/Ubuntu/Windows. 1. Root cause & the fix (one-line behavioral pivot)The whole fix is making // packages/acp-bridge/src/bridge.ts
- let originatorClientId: string | undefined;
- try {
- originatorClientId = resolveTrustedClientId(entry, context?.clientId);
- } catch (err) {
- return Promise.reject(err); // ← async: HTTP route had already sent 202
- }
+ const originatorClientId = resolveTrustedClientId(entry, context?.clientId);
// ↑ throws synchronously, like PromptQueueFullError → route can reject before 202The 2. Build + typecheck — exit 0
3. Unit tests — 981 passed across the 3 touched suites
4. Independent real-HTTP e2e (my own harness, not the PR's) — the bug→fix, demonstratedA standalone supertest spec driving the real
For the same stale client id, the pre-fix async-reject path returns 5. Mutation test — the change is non-vacuousReverted the production files to base (
Restoring the PR source → all green again.
Risk notes (low)
ci-bot is at LGTM, no unresolved review threads, CI green on all three platforms; this independent local run agrees. 中文版(完整对应)✅ 维护者本地验证 —— 构建、981 个单测、独立真实 HTTP 端到端、变异测试在 head 结论:可以安全合并。 干净、聚焦的修复;build + typecheck 均 exit 0;所有改动套件通过(981 测试);我的独立 e2e 复现了卡死客户端的 bug 并确认修复有效;变异测试证明改动被守护。CI 三平台已全绿。 1. 根因与修复(一行行为级转折)整个修复就是让 // packages/acp-bridge/src/bridge.ts
- let originatorClientId: string | undefined;
- try {
- originatorClientId = resolveTrustedClientId(entry, context?.clientId);
- } catch (err) {
- return Promise.reject(err); // ← 异步:HTTP 路由此时已经发了 202
- }
+ const originatorClientId = resolveTrustedClientId(entry, context?.clientId);
// ↑ 同步抛出,和 PromptQueueFullError 一样 → 路由能在 202 之前拒绝
2. 构建 + typecheck —— exit 0
3. 单元测试 —— 3 个改动套件 981 通过
4. 独立真实 HTTP 端到端(我自己写的,非 PR 自带)—— bug→fix 实证一个自包含 supertest 用例,驱动真实
对同一 stale client id:修复前异步 reject 路径返回 5. 变异测试 —— 改动非空过把生产文件回退到 base(
恢复 PR 源码 → 重新全绿。
风险评估(低)
ci-bot 已 LGTM,无未决 review 线程,CI 三平台全绿;本独立本地运行结论一致。 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: CI still running.
Clean, well-scoped bug fix. The synchronous-throw contract for sendPrompt is correctly implemented — InvalidClientIdError now propagates before the 202 response, both call sites (server.ts and dispatch.ts) handle the synchronous throw via try/catch, and all 981 tests pass. The new activePromptCount assertions verify no slot leak on rejection. Documentation in bridgeTypes.ts is updated to match.
— qwen3.7-max via Qwen Code /review
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No review findings. Downgraded from Approve to Comment: CI still running. Clean, well-scoped bug fix. The synchronous-throw contract for sendPrompt is correctly implemented — InvalidClientIdError now propagates before the 202 response, both call sites (server.ts and dispatch.ts) handle the sync throw in their existing try/catch, counter state stays clean (no activePromptCount leak), the daemonLog.warn branch follows the existing PromptQueueFullError pattern, and the SDK's prompt-slot release is verified by the new blocking-prompt test. Build and all targeted tests pass (296 acp-bridge + 517 cli + 168 sdk-typescript).
— qwen3.7-max via Qwen Code /review
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
* fix(sdk): self-heal stale clientId on invalid_client_id prompts After a daemon restart or session reload the daemon's in-memory client registration is wiped, so a prompt sent with our now-unknown clientId is rejected at admission with 400 invalid_client_id (PR #5784). That rejection happens before the turn registers, so the prompt never ran and retrying cannot double-execute. DaemonSessionClient.prompt() now wraps the admission call (both the blocking and non-blocking paths) in a self-heal: on invalid_client_id it re-registers via resumeSession to obtain a fresh clientId and retries the admission exactly once. A single-flight guard coalesces concurrent prompts so re-registration happens once; any other error (and a second invalid_client_id) propagates. Adds 6 tests covering both paths, the retry bound, the non-matching-error guard, reattach-failure propagation, and concurrent single-flight. Design: docs/superpowers/specs/2026-06-24-daemon-clientid-self-heal-design.md 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * test(integration): cover real-daemon invalid_client_id contract for self-heal Adds an integration case to qwen-serve-routes.test.ts validating the three real-daemon behaviors DaemonSessionClient's clientId self-heal depends on: (1) an unregistered prompt clientId is rejected at admission with 400 invalid_client_id, (2) resume re-registers and mints a fresh clientId, and (3) retrying admission with that clientId is accepted (202). Model-free: prompt admission runs before any model call, so promptNonBlocking returns 202 on acceptance without reaching the model. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(sdk): Adjust daemon browser bundle budget Co-authored-by: Qwen-Coder <[email protected]> * test(sdk): Cover clientId self-heal review cases Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
What this PR does
This PR makes stale or unregistered prompt client ids fail during prompt admission instead of becoming asynchronous prompt failures after the HTTP route has already accepted the request. The bridge now treats invalid prompt client ids the same way it already treats queue-full and pre-aborted prompt admission failures, and the daemon route returns the existing
invalid_client_idresponse shape without apromptId.The tests cover the bridge admission contract, the daemon
/promptresponse shape, and the SDK non-blocking prompt error mapping.Why it's needed
A daemon restart or session reload can issue a new client id while a UI still has an older one. Before this change,
/promptcould return202 Acceptedfor that stale client id, then the bridge would reject asynchronously before any turn was registered, so noturn_completeorturn_errorevent would ever arrive for the accepted prompt. Clients that rely on the returnedpromptIdcould stay in a processing state indefinitely.Returning
400 invalid_client_idbefore acceptance keeps the client out of the pending-prompt path and lets existing UI error handling clear the active prompt state.Reviewer Test Plan
How to verify
Run the targeted prompt admission tests and confirm stale prompt client ids return
400 invalid_client_idwithout apromptId, while true asynchronous prompt failures still return202and rely on turn events. Also confirm the bridge rejects unregistered prompt clients synchronously and the SDK maps the response toDaemonHttpErrorinstead of creating an accepted non-blocking prompt.Evidence (Before & After)
Before: a stale prompt client id could be accepted with
202, then fail asynchronously before any terminal turn event was emitted, leaving the client waiting on a prompt id that would never complete.After: the route-level regression test verifies stale prompt client ids return
400 invalid_client_idwith nopromptId, while the existing asynchronous failure test still verifies true turn failures remain on the202path.Tested on
Environment (optional)
Node.js v22.22.3. Local targeted Vitest coverage was run for
packages/acp-bridge,packages/cli, andpackages/sdk-typescript.Risk & Scope
sendPromptwith an invalid client id and expected a rejected Promise now receive a synchronous admission throw, matching the documented non-async prompt admission contract.invalid_client_idresponse shape.Linked Issues
N/A
中文说明
What this PR does
这个 PR 将 stale 或未注册的 prompt client id 改为在 prompt admission 阶段失败,而不是在 HTTP 路由已经接受请求后再异步失败。bridge 现在把无效 prompt client id 视为和队列已满、预先 abort 的 prompt 一样的 admission failure,daemon 路由会返回既有的
invalid_client_id响应,并且不返回promptId。测试覆盖了 bridge admission 契约、daemon
/prompt响应形状,以及 SDK 非阻塞 prompt 的错误映射。Why it's needed
daemon 重启或 session reload 后可能会签发新的 client id,而 UI 仍然持有旧 client id。修改前,
/prompt可能会对这个 stale client id 返回202 Accepted,随后 bridge 在任何 turn 注册之前异步拒绝,因此这个已接受 prompt 不会收到turn_complete或turn_error事件。依赖返回promptId的客户端可能会一直停留在处理中状态。在接受请求前返回
400 invalid_client_id,可以让客户端不进入 pending-prompt 路径,并让现有 UI 错误处理清理 active prompt 状态。Reviewer Test Plan
How to verify
运行针对 prompt admission 的测试,确认 stale prompt client id 返回
400 invalid_client_id且没有promptId,同时真实的异步 prompt 失败仍然返回202并依赖 turn 事件。还需要确认 bridge 会同步拒绝未注册 prompt client,SDK 会把响应映射为DaemonHttpError,而不是创建一个已接受的非阻塞 prompt。Evidence (Before & After)
Before:stale prompt client id 可能先被
202接受,然后在任何终止 turn 事件发出前异步失败,导致客户端等待一个永远不会完成的 prompt id。After:路由层回归测试验证 stale prompt client id 返回
400 invalid_client_id且没有promptId,同时已有异步失败测试仍验证真实 turn 失败保持在202路径。Tested on
Environment (optional)
Node.js v22.22.3。本地针对
packages/acp-bridge、packages/cli和packages/sdk-typescript运行了相关 Vitest 测试。Risk & Scope
sendPrompt并期望拿到 rejected Promise,现在会收到同步 admission throw;这符合已文档化的非 async prompt admission 契约。invalid_client_id响应形状。Linked Issues
N/A