fix(core): recognize OpenAI SDK APIUserAbortError as an abort - #8399
Conversation
The OpenAI SDK is the request path for `auth_type=openai` — the most common provider here. When a user cancels an in-flight request the SDK throws `APIUserAbortError`, but `isAbortError` only matched `.name === 'AbortError'` or the Node `ABORT_ERR` code. `APIUserAbortError` sets neither (its `.name` stays 'Error'), so a user cancel was not recognized as an abort. The consequences flow through the two callers that gate on `isAbortError`: the cancel is logged/telemetered as an `api_error` instead of suppressed, and the retry classifier labels it `'unknown'` instead of `'abort'`, so it misses the authoritative no-retry short-circuit. Recognize `APIUserAbortError` by its class name (preserved by the build's `keepNames`), keeping this provider-agnostic util free of an SDK import. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Re-run on head Template ✓ — all required sections present. Problem — observed, not theoretical. The real Direction — aligned; the prior escalation is resolved. A fork suppressing a telemetry event inside Size — small and focused. Core paths: Approach — right-sized, and the description now matches it. The one real issue from the last pass — the stale body still describing the split-out Risk — no elevated signals. Neither changed production file matches a revert-correlated path. Moving on to code review. 🔍 中文说明在 head 模板 ✓ —— 必填章节齐全。 问题 —— 已观测到,非理论性。 即 #8356 / #8398 报告的真实 方向 —— 对齐;此前的升级(escalation)已解决。 fork 在 规模 —— 小而聚焦。 核心路径: 方案 —— 范围合理,且描述现已与之一致。 上一轮唯一真实的问题——正文仍在描述已拆出的 风险 —— 无升级信号。 两个改动的生产文件均不命中与 revert 相关的高风险路径。 进入代码审查。🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code review (head
|
| Check | Conclusion |
|---|---|
| Test (ubuntu-latest, Node 22.x) | ✅ success |
| web-shell E2E Smoke (ubuntu-latest, Node 22.x) | ✅ success |
| Desktop Shell (ubuntu-22.04) | ✅ success |
| Desktop Shell (windows-2022) | ✅ success |
| precheck-pr / precheck | ✅ success |
| Test (macos-latest, Node 22.x) | ⏭️ skipped (merge_group-gated) |
| Test (windows-latest, Node 22.x) | ⏭️ skipped (merge_group-gated) |
| Integration Tests (CLI, No Sandbox) | ⏭️ skipped (merge_group-gated) |
CI is fully settled and green on this head — nothing pending, nothing failing.
Not verified (by this run): live TUI driving — this is an unattended re-run, so no tmux pass was driven here; the real-stack behavioral evidence is the maintainer's local run above. The vitest numbers in the PR body are the author's self-reported local run and were not independently re-run here — CI's green suite is the authoritative signal.
中文说明
代码审查(head aa6eca29,本轮重新核验)
先给独立方案: 放宽共享的 isAbortError——用类名匹配,与同文件 getErrorType 对 SDK 错误已用的手法相同,使该与 provider 无关的工具函数无需引入 SDK——另外单独给 api_error 上门:“调用方 signal 已触发 且 错误为中止形态”,与 OpenAIContentGenerator.shouldSuppressErrorLogging 已在用的两段式门一致。这正是本 PR 所做的。
isAbortError(utils/errors.ts)。 新分支——error instanceof Error && error.constructor?.name === 'APIUserAbortError'——位于 return false 之前,只会把此前为 false 的结果在且仅在这一类上变为 true。已在 base 上重新确认前提:现有检查为 name === 'AbortError' 与 code === 'ABORT_ERR',SDK 该类两者均不命中。openai 与 @anthropic-ai/sdk 均为 Stainless 生成、共享该类名;测试固定了两个正例,并有 APIConnectionError 负例防止匹配被悄悄放宽。keepNames 说明成立——CLI 产物在 esbuild.config.js 中设 keepNames: true;vscode companion 不带 keepNames,但也不使用 SDK。
遥测门(loggingContentGenerator.ts)。 safelyLogApiError 增加可选 abortSignal 参数,在 abortSignal?.aborted && isAbortError(error) 时跳过 api_error 事件;span 的中止状态仍会记录取消。我复核了全部三处调用点——非流式 catch、流建立 catch、流迭代 catch——三处现在都传入调用方的 signal(第三处 abortSignal 在作用域内,其自身 finally 也在用)。8 个调用方级测试固定了完整真值表:取消被抑制(非流、流建立、流中、DOMException 形态);真实失败、非用户导致的中止形态错误、无 signal 的请求、与取消竞态的真实失败仍会上报。
被放宽工具函数的消费方——再次走查: geminiChat(压缩 / resolve / fallback 的重抛)、shouldSuppressErrorLogging(现在能在 openai 路径捕获 SDK 取消)、artifact-tool、mcp-tool、retryErrorClassification(kind:'abort' 短路重试)、fileUtils、readManyFiles。把真实的 provider 用户取消当作 abort 处理,对每一处都是正确方向;webui / web-shell / desktop / vscode / sdk 各自保留本地 isAbortError,不受影响。
非阻塞观察(延续项,均已知且为有意取舍):
- 措辞: 正文中“内部超时侧查询保持被抑制——这是既有行为”比沙箱实测更宽松:实测发现 base 构建确实把内部超时失败记入
api_error/ 模型健康计数,是该门新近将其抑制。取舍本身是 @wenshao 在拆分时明确接受、后续 PR 要解决的事项——但若再改正文,应收紧这一句。 - Qwen 调试日志路径:
QwenContentGenerator.shouldSuppressErrorLogging仅返回isAuthError、不调super,因此即便遥测事件已被抑制,Qwen 的取消仍会出现在调试日志中。对 Qwen 是现状、非回归。 - keepNames 对类名匹配是 load-bearing,且无 CI 门禁——按已接受的意见留给后续 PR 即可。
测试证据(无人值守复跑 —— 本轮未执行 PR 代码)
针对该 head 的沙箱 @qwen-code /verify 已完成:69/69 脚本断言通过,实质性部分是 A/B 证明——base + 本 PR 测试为 RED(7 失败,恰为新断言),head 为 GREEN(148/148);编译产物层面,用户取消在第 1 次即短路重试循环,base 会重试 3 次。其发现——上述“现状”措辞与当时过时的正文——作为非阻塞项延续;正文现已更新。
那轮列出的缺口(“真实网络取消 E2E”)已由本主题下 @wenshao 的维护者本地验证补上:真实打包产物、真实终端按 ESC、遥测落盘 + wire 账本——api_error 翻转复现(base 1 次 → head 0 次),真实 HTTP 500 仍上报(4 次,每次重试一条),流中取消两侧都不产生事件,取消后 wire 上无僵尸重试。这是维护者本人的一手证据,与 diff 的承诺一致。
CI 表格见上(标记区内):该 head 的 CI 已全部落定且为绿,无 pending、无失败检查;macOS / Windows 单测与集成测试由 merge_group 门控,在 PR 上跳过。
本轮未验证: 真实 TUI 驱动——无人值守复跑,不在本地跑 tmux;真实栈行为证据即上文维护者的本地验证。PR 正文中的 vitest 数字为作者自述的本地结果,本轮未独立复跑——以 CI 绿色套件为权威信号。
— Qwen Code · qwen3.8-max
Reviewed at aa6eca291fbffe0f0a226610ebfc1201c85cde2a · re-run with @qwen-code /triage
|
Confidence: 4/5 — clean across every stage, and the behavioural claim is now proven twice over (sandboxed A/B, then the maintainer's real-stack run); what keeps it short of 5 is carried follow-up debt everyone agreed to defer, not any defect in this diff. Stepping back: the last pass deferred for two reasons, and both are now closed. The escalation — fork + core + a suppressed telemetry event — went to @wenshao, and he answered it with the strongest possible signal: a local end-to-end A/B on the real bundled build (ESC during setup flips My independent proposal for this bug was exactly what landed: broaden What remains is documented and deliberate, not blocking: the user-cancel vs internal-deadline ambiguity (the body's "pre-existing behaviour" clause is slightly looser than the sandboxed measurement — base did report those events — but the trade-off is reviewer-accepted and the follow-up inverts the polarity), the Qwen debug-log override that skips Verdict: approve. CI is settled and green on this head, the fork-refactor guardrail doesn't apply (this is a @harjothkhara — nice work seeing this through the rounds and the split. 中文说明置信度:4/5 —— 各阶段全部干净,且行为论断已被双重证明(沙箱 A/B,随后是维护者的真实栈验证);没到 5 分是因为延续到后续 PR 的既定债务,而非本 diff 有任何缺陷。 退一步看:上一轮暂缓有两个原因,现已全部关闭。升级事项——fork + 核心 + 被抑制的遥测事件——交给了 @wenshao,他以最有力的信号作答:在真实打包产物上做本地端到端 A/B(建立期 ESC 使 我对这个 bug 的独立方案与最终落地的完全一致:按类名放宽 余下的是已记录、有意为之、非阻塞的事项:用户取消与内部超时的歧义(正文“既有行为”一句比沙箱实测略宽松——base 确实上报过那些事件——但该取舍为评审者接受,后续 PR 会反转极性)、Qwen 调试日志覆写未调 裁决:批准。 该 head 的 CI 已落定且全绿,fork-refactor 护栏不适用(这是 @harjothkhara —— 这么多轮评审加一次拆分,做得漂亮。 — Qwen Code · qwen3.8-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline. Test Plan (not a blocker): src/utils/errors.test.ts — no such file or directory; src/utils/retryErrorClassification.test.ts — no such file or directory.
中文说明
已审查——无阻断问题。 建议见行内评论。 Test Plan(非阻断):src/utils/errors.test.ts — no such file or directory; src/utils/retryErrorClassification.test.ts — no such file or directory。
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
| if ( | ||
| error instanceof Error && | ||
| error.constructor?.name === 'APIUserAbortError' | ||
| ) { |
There was a problem hiding this comment.
[Suggestion] A one-provider SDK detail — the OpenAI SDK class name APIUserAbortError — is hardcoded into the provider-agnostic isAbortError. Both the OpenAI and Anthropic SDKs are Stainless-generated and happen to name their abort class APIUserAbortError, so this check covers auth_type=anthropic only by coincidence of the shared name. The codebase's established depth for this is the provider boundary: openaiContentGenerator/pipeline.ts already converts its inactivity-timeout abort into new Error('Aborted') with name='AbortError' so the generic check catches it. — Failure scenario: a future provider SDK (or a Stainless rename) whose abort class differs would not match the hardcoded string; user cancels on that path would be classified 'unknown' in classifyRetryError (retries don't stop) and logged as api_errors — the exact regression this PR fixes. The same class of defect already exists unfixed on the Anthropic path this diff doesn't touch.
Suggested fix: normalize the SDK abort at the provider boundary (e.g. in the openai/anthropic pipeline catch, when abortSignal.aborted and the error is the SDK's APIUserAbortError via a real instanceof import, rethrow a standard Error named 'AbortError'), keeping isAbortError provider-agnostic; or generalize this check so it isn't pinned to one SDK's class name.
中文说明
这里把一个 provider 专属的 SDK 细节(OpenAI SDK 的类名 APIUserAbortError)硬编码进了与 provider 无关的 isAbortError。OpenAI 和 Anthropic 两个 SDK 都由 Stainless 生成,恰好都把中止类命名为 APIUserAbortError,所以这个判断只是因类名相同而碰巧覆盖了 auth_type=anthropic。代码库中既有的恰当层次是 provider 边界:openaiContentGenerator/pipeline.ts 已经把它的空闲超时中止转换成 name='AbortError' 的 new Error('Aborted'),从而被通用判断捕获。 — 失败场景:未来某个 provider SDK(或 Stainless 重命名)的中止类名不同时,将不会匹配这个硬编码字符串;该路径上的用户取消会在 classifyRetryError 中被标为 'unknown'(重试不会停止)并被记录为 api_error —— 正是本 PR 要修复的回归。同一类缺陷在本 PR 未触及的 Anthropic 路径上已经存在且未修复。
建议修复:在 provider 边界规范化该 SDK 中止(例如在 openai/anthropic 的 pipeline catch 中,当 abortSignal.aborted 且错误是 SDK 的 APIUserAbortError 时,通过真实的 instanceof 导入,重新抛出 name='AbortError' 的标准 Error),让 isAbortError 保持与 provider 无关;或者把这个判断泛化,使其不绑定到某一个 SDK 的类名。
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
| expect(isAbortError(networkError)).toBe(false); | ||
| }); | ||
|
|
||
| it('should return true for the OpenAI SDK APIUserAbortError (user cancel)', () => { |
There was a problem hiding this comment.
[Suggestion] The new constructor-name match is pinned only on its positive side; no test asserts that sibling OpenAI SDK error classes (e.g. APIConnectionError, RateLimitError) still return false. — Failure scenario: a plausible future edit broadening the match — e.g. error.constructor?.name.startsWith('API') or .includes('API') — survives the entire suite: the new positive test stays green ('APIUserAbortError'.startsWith('API')), and every existing negative test uses Error/TimeoutError/ECONNREFUSED, none of whose constructor names start with API. Meanwhile new APIConnectionError(...) distinguishes the mutant: transient provider network/rate-limit errors on auth_type=openai would be classified as user aborts — classifyRetryError returns {kind:'abort'}, retries stop, and the error is logged as a cancellation instead of a retryable api_error.
Suggested fix:
it('should return false for other OpenAI SDK errors (e.g. APIConnectionError)', () => {
const error = new APIConnectionError({ message: 'Connection error.' });
expect(isAbortError(error)).toBe(false);
});中文说明
新增的按类名匹配只固定了正向一侧;没有测试断言 OpenAI SDK 的兄弟错误类(如 APIConnectionError、RateLimitError)仍返回 false。 — 失败场景:未来一个合理的放宽匹配改动 —— 例如 error.constructor?.name.startsWith('API') 或 .includes('API') —— 会让整个测试套件仍然通过:新的正向测试仍为绿('APIUserAbortError'.startsWith('API')),而所有既有负向测试用的是 Error/TimeoutError/ECONNREFUSED,它们的类名都不以 API 开头。而 new APIConnectionError(...) 能区分这个变异:auth_type=openai 路径上瞬时的 provider 网络/限流错误会被当成用户中止 —— classifyRetryError 返回 {kind:'abort'},重试停止,该错误被记为取消而非可重试的 api_error。
建议修复:补充一个负向测试(见上方代码块)。
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
ReviewVerdict: the code change is correct and I'd take it — but the stated benefit is only partly real. The What the PR doesAdds a third branch to What I verified locally (openai 5.11.0, the pinned version in
|
Review follow-up on QwenLM#8399. The constructor-name match was pinned only on its positive side, so broadening it (for example to any `API*` class) would have kept the whole suite green while turning transient `APIConnectionError` failures into "user cancelled" — stopping retries and mislabelling them. Add a negative test with `APIConnectionError`; verified it fails against that exact broadened match and passes against the real one. Also pin the Anthropic path. Both SDKs this package depends on are Stainless-generated and share the `APIUserAbortError` class name, so one check already covers `auth_type=anthropic` — now asserted by a test and stated in the comment, rather than left as an undocumented coincidence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
src/utils/errors.test.ts — no such file or directory; src/utils/retryErrorClassification.test.ts — no such file or directory; Tests 2 passed — this review observed 18994 passed.
中文说明
src/utils/errors.test.ts — no such file or directory; src/utils/retryErrorClassification.test.ts — no such file or directory; Tests 2 passed — this review observed 18994 passed。
— qwen3.8-max-preview via Qwen Code /review (v0.21.3)
|
@qwen-code /review CI is green on this commit — the checks flagged as "failing" were a first CI run cancelled by concurrency ~37s in, immediately superseded by a passing run ( |
|
@qwen-code /review |
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 33 passed · 0 failed · 33 total 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:33 通过 · 0 失败 · 33 总计 Verification reportVerification report — PR #8399
|
| Cell | Environment | Oracle | Result |
|---|---|---|---|
base 2d2bdab + PR test files |
scratch worktree, vitest (source) | 2 changed suites | 3 failed | 80 passed — the two positive isAbortError tests and the retry-classification test fail with the intended behavioral mismatch (expected true to be false; expected {kind:'abort'} got kind:'unknown') |
head 2fad2fd |
vitest (source) | same | 83 passed (01-ab-red-green.png) |
base dist control (head dist with only the PR hunk reverted; diff -rq = exactly 1 file) |
01-abort-matrix.mjs vs compiled dist, real SDK errors |
25 scripted checks | 25/25 as encoded: isAbortError(abort)=false, classify kind:'unknown', suppression false |
| head dist | same harness | 25 checks | 25/25: true / {kind:'abort',diagnosis:'fail-fast',reason:'aborted'} / suppressed; siblings (APIConnectionError, RateLimitError(429)), existing abort paths (DOMException AbortError, ABORT_ERR), and null/string/spoof boundary cells identical both arms (02-harness-ab-matrix.png) |
| retry loop, base | 02-retry-loop.mjs, real APIUserAbortError, permissive custom shouldRetryOnError, no signal |
attempt count | 3 attempts — the base build retries a user cancel |
| retry loop, head | same | attempt count | 1 attempt — abort-kind short-circuit fires (03-retry-loop-ab.png) |
| retry loop controls | both arms | default predicate → 1 attempt; 503 → 3 attempts | identical both arms (no regression) |
The base-arm reds are encoded as expectations (control cells), so they count as passes in assertions.json.
Premise verified against real artifacts: new APIUserAbortError({message}) from locked openai@5.11.0 and @anthropic-ai/sdk@0.36.3 carries .name === 'Error', no .code, no .status — neither pre-existing check can match it; both SDKs share the constructor name, so one clause covers both providers (confirmed empirically, not from the description).
Corrections
- Stale "After" numbers in the PR description (correction to the description, not the code): the Reviewer Test Plan's "After" snippet shows
2 passed | 79 skipped (81); at the verified head the exact command yields3 passed | 80 skipped (83)— the Anthropic-pinning test added in commit 2 also matches the-t "APIUserAbortError"filter. Behavior is confirmed; only the quoted counts predate the second commit.
Findings (no blockers)
keepNames: trueis load-bearing and unguarded by any test (Suggestion). The recognition mechanism iserror.constructor?.name === 'APIUserAbortError'; both SDKs are bundled (packages: 'bundle', not in theexternallist), so the name survives only via esbuildkeepNames: true. Proofs: the real production bundle carries 3__name(this, "APIUserAbortError")markers; a probe entry bundled with the repo'smainBuildflags passes 5/5 withkeepNames(with and without--minify); the same bundle with--minifyand without--keep-namesrenames the classes (re,me) andisAbortErrorreturnsfalse— silent breakage (04-bundle-keepnames-probes.png). Unit tests run from source, so a future build-config change (enabling minify or droppingkeepNames) would regress abort recognition in the shipped bundle with the whole suite still green. The PR comment documents the dependency; a bundled-path smoke assertion (or a comment inesbuild.config.js) would pin it. Not a merge condition — the flag is present today and both current bundle modes work.- Coverage gap: the
instanceof Errorguard on the new clause is unpinned (Suggestion). Mutation M2 (guard removed) survives the PR suite (83/83) but is killed by the harness: a non-Error spoof{constructor:{name:'APIUserAbortError'}}flipsfalse→trueunder the mutant (06-mutation-noguard-survivor.png). Behavior at head is correct; a one-line negative test would pin it. Completeness reporting, not a merge condition. - Name-match boundary: any
Errorsubclass literally namedAPIUserAbortErrormatches (informational). A locally defined class of that name returnstrueat head. This is inherent to the constructor-name mechanism the codebase already uses ingetErrorType(the PR comment cites it), and its blast radius is limited to log suppression and classification labels. Acceptable, documented tradeoff.
Mutation matrix (positive controls quoted beside survivors):
| Mutant | Suite that should catch it | Result |
|---|---|---|
| M0 clause deleted (= base) | the 3 new tests | killed — 3 failed with intended assertions (01-ab-red-green.png) |
M1 match broadened to startsWith('API') |
the APIConnectionError negative test |
killed — exactly 1 failed | 82 passed, failing test is the negative, on expected true to be false (05-mutation-broaden-killed.png) |
M2 instanceof Error guard removed |
(none in PR suite) | survives PR suite (83 passed); killed by harness spoof cells → coverage gap, not dead code |
The anthropic-pinning test cannot be killed by any single-point production mutant without also killing the openai test (both SDKs traverse the one shared clause); its value is intent documentation, and M1 proves the suite can go red for the right reason.
Not covered
- Live end-to-end cancel: no run against a real OpenAI-compatible endpoint with a mid-stream user cancel. The harnesses reproduce the wire shape the SDK throws (
APIUserAbortErrorconstructed from the locked SDKs), not the network-side trigger that produces it; the suppression cell mirrors the 3-lineshouldSuppressErrorLoggingexpression rather than driving the generator class. - Full-repo and CLI-package suites: not run here (targeted core suites only — 28 files / 1709 tests green, incl. every
isAbortErrorcaller). The author's claim that CLISession/FileCommandLoadersuites are green was not independently re-run. - Per-commit attribution: the checkout is shallow (grafted); only
HEAD^2is reachable while the metadata lists 2 commits, so commit 1's fix and commit 2's test pinning were verified as the aggregateHEAD^1..HEADdiff (commit 2's claim was additionally verified behaviorally via M1). - Telemetry channel: the suppression boolean and classification kind are asserted at the function level; no
api_error/api_retrytelemetry event was captured end-to-end. - Anthropic runtime path beyond class-name identity (no Anthropic API traffic).
Methodology
Environment: node:22-bookworm CI container, Node v22.23.2, locked openai@5.11.0 / @anthropic-ai/sdk@0.36.3 / vitest 3.2.4; head build pre-existing (dist/ compiled). A/B base arm 1 = scratch git worktree at HEAD^1 (2d2bdab) with the PR's two test files copied in (vitest runs source; openai resolves to the shared root node_modules — asserted, and packages/core has no @qwen-code/* deps, so no workspace-symlink confound); base arm 2 = byte-exact copy of head's compiled dist with only the PR hunk reverted (diff -rq = 1 file). Harnesses (01-abort-matrix.mjs, 02-retry-loop.mjs, bundle-probe-entry.ts, 90-final-tally.sh) are mock-free: real SDK error instances against compiled dist, real retryWithBackoff call site, and esbuild probes replicating esbuild.config.js mainBuild flags (packages:'bundle', keepNames, platform/target, esbuild-shims.js inject). Bundle corroboration: static grep of the real dist/ for __name markers + runtime probe. Raw logs in logs/ (01–13), captures in evidence/ (01–06), tallied by 90-final-tally.sh whose 33 checks each encode their expectation (expected base-arm reds count as passes; inner harness comparisons are folded into their run's inner N/N count check). Scratch worktrees removed after capture; repo git status clean.
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
| _Qwen Code review request accepted. Review is queued in [workflow run](https://github.com/QwenLM/qwen-code/actions/runs/31243170056)._ |
|
⏸️ Deferring to @wenshao — the re-run at ⏸️ 交还 @wenshao —— 针对 — Qwen Code · qwen3.8-max Reviewed at |
|
Triage re-run completed without a new review.
The stage comments above were updated with the latest result. View workflow run. 上方各阶段评论已更新为最新结果。查看工作流运行。 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
src/utils/errors.test.ts — no such file or directory; src/utils/retryErrorClassification.test.ts — no such file or directory; Tests 2 passed — this review observed 18994 passed.
中文说明
src/utils/errors.test.ts — no such file or directory; src/utils/retryErrorClassification.test.ts — no such file or directory; Tests 2 passed — this review observed 18994 passed。
— qwen3.8-max via Qwen Code /review (v0.21.4)
|
Verified this locally against the current head, since the fork CI is still pending approval. The premise checks out: on One observation on the description, non-blocking: the stated goal is to stop cancels from being "logged / telemetered as Nice addition in the second commit pinning the negative case against |
@wenshao and @yiliang114 verified the isAbortError fix but showed the api_error the reporter saw is not gated by it: it comes from LoggingContentGenerator.safelyLogApiError, which emitted ApiErrorEvent unconditionally. So a user cancel still produced a qwen-code.api_error event (error_type APIUserAbortError) after the util-level fix. Gate safelyLogApiError — when the caller's signal is aborted and the error is abort-shaped, skip the event; the span already records the cancellation via its aborted status, so the signal isn't lost. Thread the abort signal through the three call sites. Adds a caller-level regression test asserting no api_error fires on a user cancel, plus a contrast test that a real failure still reports. Also from the review: extend the isAbortError JSDoc for the third shape, scope the keepNames comment to the CLI bundle (vscode-ide-companion minifies without keepNames), and assert `.not.toBe('AbortError')` rather than the brittle SDK-internal `.name === 'Error'`. Refs: QwenLM#8398 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks both — the telemetry gap was the catch that mattered, and I've finished the job rather than narrowing the claim ( Telemetry (wenshao #1 / yiliang114's observation) — gated. You're right that the Mechanism / convention (#2) — explicit ack. Agreed this inverts the producer-normalizes pattern ( JSDoc (#3) / test nits (#4) — done. JSDoc now names the third shape. Both Retry blast radius (#5) / pre-existing gaps (#6) — noted, untouched. The
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
中文说明
已审查——无阻断问题。 建议见行内评论。
— qwen3.8-max via Qwen Code /review (v0.21.3)
| if (abortSignal?.aborted && isAbortError(error)) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
[Suggestion] The gate's truth table is only half pinned — both single-conjunct mutants survive the whole suite (measured). — Failure scenario: the mutant if (isAbortError(error)) return; (drops the signal condition) passes the directory suite 75/75 — a network-induced abort the user did not cause would then silently lose its api_error entry; the mutant if (abortSignal?.aborted) return; (drops the error-shape condition) also passes 75/75 — a genuine 401/429 racing a user cancel would silently drop from telemetry. The sibling predicate shouldSuppressErrorLogging pins both boundary cases in openaiContentGenerator.test.ts (should return false for AbortError when signal is NOT aborted (network abort) / should return false for non-AbortError even when signal is aborted); this gate pins neither.
Suggested tests:
// abort-shaped error, signal NOT aborted -> still reported
// reject with new APIUserAbortError({ message: 'Request was aborted.' })
// and a request config whose abortSignal is NOT aborted
expect(logApiError).toHaveBeenCalledTimes(1);
// aborted signal, non-abort-shaped error -> still reported
// the pre-existing aborted-partial-stream test (~line 1731) already drives
// this combination; one added assertion there pins it
expect(logApiError).toHaveBeenCalledTimes(1);中文说明
门控的真值表只固定了一半——两个"单条件"变异体都能在整个套件中存活(已实测)。 — 失败场景:变异体 if (isAbortError(error)) return;(去掉 signal 条件)在目录套件 75/75 全绿——用户未触发的网络中止会静默丢失 api_error 遥测条目;变异体 if (abortSignal?.aborted) return;(去掉错误形态条件)同样 75/75 全绿——与用户取消竞态发生的真实 401/429 会静默地从遥测中消失。姊妹判断 shouldSuppressErrorLogging 在 openaiContentGenerator.test.ts 中固定了这两个边界用例("signal 未中止时 AbortError 不抑制"/"signal 已中止但非 AbortError 不抑制");本门控两者都未固定。
建议补充两个测试(见上方代码块):中止形态错误 + 未中止的 signal → 仍应上报;已中止的 signal + 非中止形态错误 → 仍应上报(约 1731 行既有的 aborted-partial-stream 测试已驱动后一组合,补一条断言即可固定)。
— qwen3.8-max via Qwen Code /review (v0.21.3)
| this.safelyLogApiError( | ||
| '', | ||
| durationMs, | ||
| error, | ||
| req.model, | ||
| userPromptId, | ||
| req.config?.abortSignal, | ||
| ), |
There was a problem hiding this comment.
[Suggestion] The streaming call sites of the cancel gate are untested. — Failure scenario: the gate is only tested through the non-stream generateContent path; a mid-stream Escape cancel (the most common cancel UX) surfaces as APIUserAbortError inside the for await and hits the iteration call site (~line 825), which no test covers. If a future refactor drops req.config?.abortSignal at either streaming call site, the gate's first conjunct becomes undefined and the qwen-code.api_error noise this PR fixes silently returns for stream cancels while the suite stays green (this class has exactly one test consumer, verified by grep). Probe-verified: appended stream tests pass with the gate in place and flip to failing when it is removed.
Suggested test:
// generateContentStream whose wrapped stream throws
// new APIUserAbortError({ message: 'Request was aborted.' })
// with an aborted config.abortSignal
expect(logApiError).not.toHaveBeenCalled();中文说明
取消门控的流式调用点没有测试覆盖。 — 失败场景:门控目前只通过非流式 generateContent 路径被测到;流式输出中途按 Escape 取消(最常见的取消交互)会以 APIUserAbortError 从 for await 中抛出,命中约 825 行的迭代调用点,而该点没有任何测试覆盖。若未来重构在任一流式调用点漏传 req.config?.abortSignal,门控的第一个条件变为 undefined,本 PR 修复的 qwen-code.api_error 噪声会在流式取消场景下静默回归,而测试套件仍然全绿(已用 grep 确认该类只有一个测试消费方)。已用探针验证:补充的流式测试在门控存在时通过、移除门控后变红。
建议补充一个测试(见上方代码块):generateContentStream 的包装流抛出 APIUserAbortError 且 config.abortSignal 已中止,断言 logApiError 未被调用。
— qwen3.8-max via Qwen Code /review (v0.21.3)
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Test Plan (not a blocker): src/tools/read-file.test.ts — no such file or directory; src/tools/zoom-image.test.ts — no such file or directory; 371 tests green — this review observed 18571, 19521, 1124, 1466, 481, 2941, 454 passed; Tests 19297 passed — this review observed 18571, 19521, 1124, 1466, 481, 2941, 454 passed.
中文说明
Test Plan(非阻断):src/tools/read-file.test.ts — no such file or directory; src/tools/zoom-image.test.ts — no such file or directory; 371 tests green — this review observed 18571, 19521, 1124, 1466, 481, 2941, 454 passed; Tests 19297 passed — this review observed 18571, 19521, 1124, 1466, 481, 2941, 454 passed。
— qwen3.8-max via Qwen Code /review (v0.21.7)
| timeoutController.abort( | ||
| new Error(`Goal verifier timed out after ${timeoutMs}ms`), | ||
| timeoutAbortReason(`Goal verifier timed out after ${timeoutMs}ms`), |
There was a problem hiding this comment.
[Critical] The seventh unconverted deadline producer: the sibling goal checkpoint verifier (packages/core/src/goals/goal-checkpoint-verifier.ts:156-160) still aborts its 30s deadline with a plain new Error(...), so isUserCancel reads its timeouts as user cancels and this diff's new gates suppress its api_error telemetry and debug log — the exact misclassification this hunk fixes for goal-verifier.ts. — Failure scenario: a goal checkpoint verification LLM call exceeds its 30s budget mid-request (wired in production at config.ts:7430, invoked from goal-runtime.ts:822; the signal composes via AbortSignal.any and routes through runSideQuery → baseLlmClient → LoggingContentGenerator) → timeoutController.abort(new Error(...)) fires with reason .name === 'Error' → the SDK rejects abort-shaped → isUserCancel returns true → qwen-code.api_error is skipped and the debug log is suppressed. Before this diff the same timeout was reported; the diff newly hides it behind a clean model-health chart. Probe-verified: plain-Error reason → isUserCancel true; with timeoutAbortReason(...) → false.
Fix: mirror this conversion in goal-checkpoint-verifier.ts (import timeoutAbortReason from ../utils/errors.js):
timeoutController.abort(
timeoutAbortReason(`Goal checkpoint verifier timed out after ${timeoutMs}ms`),
);plus a reason-shape test mirroring goal-verifier.test.ts.
中文说明
[Critical] 第七个未转换的超时产生方:孪生的 goal checkpoint verifier(packages/core/src/goals/goal-checkpoint-verifier.ts:156-160)仍以普通 new Error(...) 中止其 30 秒截止时限,isUserCancel 会把它的超时判为用户取消,本 diff 新增的门控因此抑制其 api_error 遥测与调试日志——正是此处为 goal-verifier.ts 修复的同一误判。——失败场景:goal checkpoint 校验的 LLM 调用在请求进行中超出 30 秒预算(生产环境经 config.ts:7430 装配、由 goal-runtime.ts:822 调用;signal 经 AbortSignal.any 合成并沿 runSideQuery → baseLlmClient → LoggingContentGenerator 传入)→ timeoutController.abort(new Error(...)) 以 .name === 'Error' 的 reason 触发 → SDK 以中止形态拒绝 → isUserCancel 返回 true → qwen-code.api_error 被跳过、调试日志被抑制。本 diff 之前同一超时是会上报的;合并后它会隐藏在干净的模型健康图表背后。已用探针验证:普通 Error reason → isUserCancel 为 true;改用 timeoutAbortReason(...) 后 → false。
修复:在 goal-checkpoint-verifier.ts 中镜像此转换(从 ../utils/errors.js 导入 timeoutAbortReason),并补充一个与 goal-verifier.test.ts 对应的 reason 形态测试(代码见上方英文代码块)。
— qwen3.8-max via Qwen Code /review (v0.21.7)
Review follow-up on QwenLM#8399, round 10. The goal checkpoint verifier is a sibling of goal-verifier with the same 30s deadline and the same plain-Error abort reason that goal-verifier had before round 7 -- so its timeouts read downstream as user cancels and the gates this PR adds suppressed its api_error and debug log. Convert it the same way, via timeoutAbortReason, and extend its existing timeout test with the reason-shape assertion; reverting the conversion fails exactly that test. This corrects the round-9 audit claim: that sweep matched bare abort() calls and missed this site because it aborts with an argument of the wrong shape. Re-swept for abort(new Error and abort(' string reasons on timer paths; no further instance reaches a model request. Refs: QwenLM#8398 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ract Add docs/design for the TimeoutError-reason invariant this PR introduces: why abort-shaped is not a proxy for user intent, the isUserCancel / timeoutAbortReason split, the converted producers and the two deliberately bare aborts, and the honest limits — the invariant is convention not a type, and broadening isAbortError touches every consumer, not just the two refined gates. States plainly that this does not fix QwenLM#8356's transcript blackout. Also soften two code comments that asserted QwenLM#8356 was *caused by* the logging-path divergence. The issue does not establish that; the divergence is what QwenLM#8398 fixes. Point the comments at QwenLM#8398 instead. Refs: QwenLM#8398 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@wenshao — when you have a moment, could you take a look at the direction here? This started as the small #8398 fix (recognize The automated reviews have been thorough on correctness, but no human has weighed in on the scope — whether this cross-cutting invariant belongs in one PR, or should be split (recognition first, the invariant second). I wrote it up in To be clear on linkage: this fixes #8398 and the @wenshao 有空时能否看下整体方向?这个 PR 由 #8398 的小修复(识别 |
|
Thanks for writing the design doc — it made this reviewable, and the "Limits" My read: split it, but not for size reasonsThere are two things in this PR:
I do want these split, but the reason isn't diff size. It's that the invariant's The polarity is the decision that matters
Your doc says exactly this and calls it convention-not-type. I'd go further —
The opposite polarity — suppress only aborts explicitly tagged as a user Your stated reason for negative-only is to avoid enumerating cancel shapes. But Specific points
ConcretelyLand #8399 as recognition + the telemetry gate only — Then the invariant as its own PR, where we settle the polarity first. Happy to 中文先谢谢你写了设计文档——它让这个 PR 可评审了。我想从 "Limits" 那一节谈起,因为我认为它低估了自己提出的问题。 我的意见:该拆,但不是因为体量这个 PR 里其实是两件事:
我确实希望拆开,但理由不是 diff 大小,而是这个约定的判别极性还没定下来——一旦合入就很难再翻转。 真正需要决定的是极性
你的文档正是这么写的,并称之为「是约定而非类型」。我想再往前推一步——这个 PR 自身的证据说明默认值站错了边:
相反的极性——只抑制被显式标记为用户取消的 abort——把这两点都反了过来。取消方的集合是封闭且稳定的(TUI 回合取消、ACP/daemon、webui),而不是开放增长的;而且其中一处已经有标记了: 你选择 negative-only 的理由是避免枚举取消形态。但枚举恰恰是让这个约定可执行的前提。既然它无法成为类型,那默认值就必须落在安全的那一侧。 几个具体点
具体建议#8399 只保留识别 + 遥测门控——修好 约定单独开一个 PR,先把极性定下来。那个 PR 我很乐意单独评审。 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Test Plan (not a blocker): src/tools/read-file.test.ts — no such file or directory; src/tools/zoom-image.test.ts — no such file or directory.
中文说明
Test Plan(非阻断):src/tools/read-file.test.ts — no such file or directory; src/tools/zoom-image.test.ts — no such file or directory。
— qwen3.8-max via Qwen Code /review (v0.21.8)
| Converted deadline producers, all reaching a model request: goal judge, goal | ||
| verifier, goal checkpoint verifier, prompt hook, stall watchdog, workflow | ||
| wall-clock cap, and the CLI voice-transcript refinement. |
There was a problem hiding this comment.
[Critical] R11-1: The completeness audit misses the daemon-boundary deadline producer. The daemon prompt deadline (PromptDeadlineExceededError, fired in packages/acp-bridge/src/bridge.ts onDeadline) and the live-call turn timeout reach model requests laundered as the string reason 'qwen:user-cancel': the forwarded ACP cancel (forwardRunningPromptCancel) carries { sessionId } only — the cause never crosses the boundary — and the agent side aborts with USER_CANCEL_ABORT_REASON (Session.cancelPendingPrompt → pendingPrompt.abort('qwen:user-cancel')). isUserCancel then reads a string reason (not TimeoutError) and returns true, so the new gates suppress the api_error event, apiActivityTracker.recordError() and the debug log for a genuine deadline failure. Before this diff no gate existed, so the event was emitted — the suppression is newly introduced, the same regression class the five prior Criticals on this PR fixed. The deliberate exclusions above (ACP recovered-parent wait, runBudget) do not cover this: it is a deadline aborting an in-flight model request, not a planned interruption. Probe-verified: isUserCancel(APIUserAbortError, signal aborted with 'qwen:user-cancel') → true. — Failure scenario: an operator runs qwen serve with a prompt deadline (or an SDK client sends deadlineMs) and the deadline fires while a model request is in flight → the deadline kill is laundered to 'qwen:user-cancel' → api_error, model-health entry and debug log are all suppressed → an unattended daemon's LLM work dies on deadlines behind a clean model-health chart.
Suggested fix (spans files outside this diff, so no suggestion block): propagate deadline attribution across the ACP cancel boundary — include the cause in the forwarded cancel (forwardRunningPromptCancel / PROMPT_CANCEL_METHOD), and have Session.cancelPendingPrompt abort with timeoutAbortReason(...) for deadline-caused cancels while genuine client cancels keep 'qwen:user-cancel'. Alternatively, explicitly add the daemon prompt deadline to this exclusions list with a justification (the harder case — unlike runBudget it is not a planned interruption of a healthy request).
中文说明
[Critical] R11-1:完整性审计遗漏了 daemon 边界上的超时产生方。daemon 提示词截止时限(PromptDeadlineExceededError,在 packages/acp-bridge/src/bridge.ts 的 onDeadline 中触发)与实时通话轮次超时会以字符串 reason 'qwen:user-cancel' 的形态到达模型请求:转发的 ACP 取消(forwardRunningPromptCancel)只携带 { sessionId }——原因没有跨越边界——agent 一侧以 USER_CANCEL_ABORT_REASON 中止(Session.cancelPendingPrompt → pendingPrompt.abort('qwen:user-cancel'))。isUserCancel 因此读到字符串 reason(而非 TimeoutError)并返回 true,新门控便对真实的截止时限失败抑制 api_error 事件、apiActivityTracker.recordError() 与调试日志。本 diff 之前不存在门控、该事件原本会发出——抑制是新引入的,与本 PR 此前五个 Critical 所修复的回归类型相同。上文的有意排除项(ACP recovered-parent 等待、runBudget)并不覆盖此情形:这是一个中止进行中模型请求的截止时限,而非计划性中断。已用探针验证:isUserCancel(APIUserAbortError, signal aborted with 'qwen:user-cancel') → true。——失败场景:运维以提示词截止时限运行 qwen serve(或 SDK 客户端发送 deadlineMs),截止时限在模型请求进行中触发 → 该超时中止被洗成 'qwen:user-cancel' → api_error、模型健康记录与调试日志全部被抑制 → 无人值守 daemon 的 LLM 工作在干净的模型健康图表背后死于超时。
建议修复(跨本 diff 之外的文件,故不提供 suggestion 代码块):在 ACP 取消边界传播截止时限归因——在转发的取消中携带原因(forwardRunningPromptCancel / PROMPT_CANCEL_METHOD),并让 Session.cancelPendingPrompt 对超时导致的取消以 timeoutAbortReason(...) 中止,真实的客户端取消保持 'qwen:user-cancel'。或者,在本排除列表中显式加入 daemon 提示词截止时限并给出理由(较难成立——与 runBudget 不同,它不是对健康请求的计划性中断)。
— qwen3.8-max via Qwen Code /review (v0.21.8)
Round-11 review (R11-1) found the producer audit's completeness claim was false: the daemon prompt deadline reaches a model request laundered as the 'qwen:user-cancel' string (reason re-stamped at the Session admission boundary, after the cause is dropped at the ACP wire), so isUserCancel reads it as a cancel and suppresses its provider-health api_error. Stop the design note claiming the negative-only invariant is self-enforcing, and document this producer honestly: the deadline is still surfaced via the prompt_deadline_exceeded terminal and an errored LLM span, so only the llmApiErrors count is affected; whether that count should include a caller-configured local deadline is a semantic question (llmApiErrors is documented provider-side) left for a maintainer, with the cross-boundary _meta fix noted if it is ruled a regression. No behavior change; this commit is documentation only. Refs: QwenLM#8398 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@wenshao — a specific semantic call I need your judgment on, from the round-11 review (R11-1). The bot flagged that a daemon prompt deadline ( Crucially it is not a blackout: the deadline is still published as the So the question is genuinely yours to decide:
This is the same wire-boundary escalation as my earlier scope question — it's the one producer that can't be fixed in-process. No rush; I'd rather have your read than guess. @wenshao 需要你就 R11-1 做一个语义判断。守护进程 prompt 截止( |
…the invariant Per @wenshao's review: land the bug fix here, move the cross-cutting "internal deadlines must signal TimeoutError" invariant to its own PR so its polarity (default-suppress vs default-report) can be decided before it merges. This PR now contains only: - isAbortError recognizes the OpenAI SDK's APIUserAbortError (the QwenLM#8398 fix). - safelyLogApiError skips the api_error event on a user cancel, using the approximately-correct gate `abortSignal?.aborted && isAbortError(error)`. This stops the QwenLM#8398 noise immediately. Internal timeouts remain suppressed under this gate — that is the status quo, not a regression. Removed and deferred to the follow-up PR: isUserCancel, timeoutAbortReason, the seven producer conversions (goals, hooks, workflow, voice), the Qwen override composition, and the design note. Refs: QwenLM#8398 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — this is the right call, and I've done the split. This PR is now just the #8398 fix: On your point about polarity — you've convinced me the default is on the wrong side. The invariant PR will open with positive-only (suppress only explicitly-tagged user cancels), starting from the closed cancel-producer set (TUI / ACP-daemon / webui) and the existing Your other specifics, carried to the invariant PR:
I'll open the invariant PR separately and tag you. Thanks for the thorough read. |
|
@qwen-code /triage |
|
Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 47 passed · 0 failed · 47 total 中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:47 通过 · 0 失败 · 47 总计 Verification reportSandboxed verification: Ran the PR in an isolated, token-free container: A/B against the new base build, mock-free harness assertions against the compiled dist, mutation matrix, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 47 passed · 0 failed · 47 total 中文 — 判定:
|
| # | Finding (round at aa6eca2 vs e20601d) |
Severity | Status at aa6eca2 vs d96de59 |
|---|---|---|---|
| 1 | Internal deadline failures stop reaching api_error telemetry / the model-API-health chart; the reduction's "status quo, not a regression" claim is false |
Suggestion | stands — re-measured: deadline probes (controller+timer and AbortSignal.timeout, side-query prompt ids) record 1 at base d96de59 and 0 at head (02-gate-ab-telemetry-destination.png, D1/D2 rows) |
| 2 | instanceof Error guard on the new clause unpinned by the suite |
Suggestion | stands — M4 (guard removed) survives all 148 tests at the new head; killed by the spoof probe with the intended assertion (05-mutation-matrix-and-keepnames.png) |
| 3 | Any locally declared class named APIUserAbortError matches, and flips retry classification to kind:'abort' |
Informational | stands — re-measured in the matrix INFO cell: local Error subclass with the same constructor name → isAbortError=true, kind=abort at head; false/unknown at base (03-abort-matrix-head-vs-base.png) |
| 4 | Correction: body described the pre-reduction scope (stale) | Correction | superseded — the body was rewritten for the reduced scope and now matches the tree (isUserCancel/timeoutAbortReason absent, verified by grep = 0). The rewrite's suite numbers still do not reproduce in this container (environmental; see Finding 5), and its "pre-existing behaviour" sentence is the falsified claim carried as Finding 1 |
No declined or deferred rows from the previous round; all were re-measured, none worsened.
Central claim and A/B
Central claim: (a) isAbortError returns true for the OpenAI and Anthropic SDKs' APIUserAbortError (constructor-name match), flipping retry classification 'unknown' → 'abort'; (b) LoggingContentGenerator.safelyLogApiError skips the api_error event exactly when abortSignal?.aborted && isAbortError(error) at all three call sites (non-stream, stream setup, mid-stream), stopping the #8398/#8356 noise at the telemetry layer — while every non-cancel error still reports.
| Cell | Environment | Oracle | Result |
|---|---|---|---|
| Source RED→GREEN | base worktree d96de59 + the PR's 3 test files vs head, vitest |
3 changed suites | base 7 failed | 141 passed (148) — exactly the 7 intended mismatches (2× isAbortError positive, 1× kind:'unknown' vs 'abort', 4× expect(logApiError).not.toHaveBeenCalled()); head 148 passed (01-ab-red-green-base-vs-head.png) |
| SDK error matrix, head dist | compiled packages/core/dist, real locked SDK errors (openai 5.11.0, @anthropic-ai/sdk 0.36.3) |
isAbortError + classifyRetryError, 12 cells |
12/12: both SDK aborts true/abort; DOMException, ABORT_ERR, axios-shape unchanged; APIConnectionError/RateLimitError(429)/null/string/spoof negative; INFO cell shows the same-name boundary (03-abort-matrix-head-vs-base.png) |
| SDK error matrix, surgical base control | byte copy of head dist with exactly the two production hunks reverted (diff -rq = 2 files) |
same | 12/12 as encoded: SDK aborts false/unknown, everything else identical to head |
| Telemetry gate, head dist | real LoggingContentGenerator from dist, fake provider adapter as the seam, observed at two real destinations (apiActivityTracker.drain() and the uiTelemetryService.addEvent stream) |
errors recorded per cell, 11 cells | 11/11: 5 cancel shapes (non-stream, stream-setup, mid-stream DOMException, mid-stream SDK, daemon string reason) recorded 0; 4 report cells (real failure, race, abort-shaped-no-signal, DOMException-no-signal) recorded 1; both deadline probes recorded 0 (02-gate-ab-telemetry-destination.png) |
| Telemetry gate, base control | same | same | 11/11 as encoded: every error reaching the catch recorded 1, including the five cancel cells and both deadline probes |
| Retry loop, head vs base | real retryWithBackoff from dist, maxAttempts:3, permissive predicate |
attempt + onRetry counts | user cancel: head 1 attempt / 0 retries, base 3 attempts / 2 retries; 503 controls 3 attempts both arms; error identity preserved (04-retry-loop-ab.png) |
Base-arm reds are encoded as expectations (control cells), so they count as passes.
Mutation matrix (each mutant killed by exactly its pinned tests; attribution verified per failed-test name — 05-mutation-matrix-and-keepnames.png):
| Mutant | Result |
|---|---|
| M0 revert both hunks (= base) | killed — the 7 RED failures above |
M2a drop abortSignal?.aborted && |
killed — 2 failed (the two "abort-shaped the user did not cause / no signal" tests) |
M2b drop isAbortError(error) && |
killed — 1 failed ("real failure racing a cancel") |
| M2c drop signal arg at non-stream site | killed — 1 failed (non-stream cancel) |
| M2d drop signal arg at stream-setup site | killed — 1 failed (stream-setup cancel) |
| M2e drop signal arg at mid-stream wrapper | killed — 2 failed (both mid-stream tests) |
M3 broaden to startsWith('API') |
killed — 1 failed (the APIConnectionError negative) |
M4 remove instanceof Error guard |
survives all 148; killed by the spoof probe (isAbortError({constructor:{name:'APIUserAbortError'}}) → true) → coverage gap, not dead code; head's guard rejects the same spoof |
Corrections
- The previous round's stale-body correction is resolved at this head. The body now describes exactly the reduced scope (two changes, the split, the known limitation);
isUserCancel/timeoutAbortReasonare absent from the tree (grep = 0). What remains inaccurate is only the "pre-existing behaviour, not a regression" sentence, carried as Finding 1 with its measurement.
Findings (no blockers)
- Internal deadline failures stop reaching
api_errortelemetry and the model-API-health chart at this head, and the body's "that is the pre-existing behaviour, not a regression" is falsified at the new base (Suggestion — accepted trade-off, mischaracterized claim). Re-measured atd96de59: the two deadline probes that mirror real producer shapes reachable throughBaseLlmClient(which threads the caller's composed signal intoconfig.abortSignal; re-verifiedbaseLlmClient.tspassesabortSignalinto the request config) — a plainAbortController+timer deadline (goalHook/promptHook shape) and anAbortSignal.timeoutbudget (memory recall/forget shape), each rejecting the fake provider with the openai SDK's abort error — record 1 at base and 0 at head on both telemetry destinations (02-gate-ab-telemetry-destination.png, DEADLINE PROBE rows). So head changes those paths from reported to suppressed; the span still records the abort, but the event and the health-chart counter no longer see it until the follow-up PR lands. The deferral is reviewer-directed, which is why this is a finding about the claim's wording and the deferral, not a blocked verdict. Reproduce:node tmp/pr8399-verify-20260812-030112/harness/02-gate.mjs <dist> head|base. Reviewers should confirm the deferral is intentional at this reduced scope and that the follow-up is tracked; a one-line body edit ("becomes suppressed at this head; the follow-up restores reporting") would make the claim true. instanceof Errorguard unpinned (Suggestion, carried over). Reproduce: delete theerror instanceof Error &&conjunct inpackages/core/src/utils/errors.ts, runcd packages/core && npx vitest run src/utils/errors.test.ts src/utils/retryErrorClassification.test.ts src/core/loggingContentGenerator/loggingContentGenerator.test.ts→ 148/148 green; thenisAbortError({constructor:{name:'APIUserAbortError'}})returns true. A one-line negative test would pin it.keepNamesload-bearing, unguarded by any test or CI gate (Suggestion, carried over). Re-run at the new head:esbuild.config.jsis unchanged by the PR (keepNames: trueat lines 241/267), the shipped chunks carry 3__name(this, "APIUserAbortError")markers, and the esbuild probe shows the shipped config (keepNames, no minify) and keepNames+minify both preserve recognition, while no-keepNames+minify renames the class toEand recognition silently goes false (05-mutation-matrix-and-keepnames.png, bottom rows).- Name-match boundary extends to retry classification (Informational, carried over): a locally declared
Errorsubclass namedAPIUserAbortErroris recognized and classifiedkind:'abort'at head (matrix INFO cell), so it would also short-circuit retries. Inherent to the constructor-name mechanism; limited blast radius; documented trade-off in the code comment. - Body/test-plan numbers do not reproduce in this container (Informational, environment). The body's
npx vitest run --root packages/coreclaim (19575 passed, 3 failed, 11 skipped) matches neither arm here: head measures72 failed | 19595 passed | 10 skipped (19677), base72 failed | 19499 passed | 10 skipped (19581)(the 96-test delta decomposes exactly: 12 new PR tests + 84 preset tests that failed collection in the base worktree). The 72 are this container's HOME-content environmental failures (logger checkpoints, ide-client, memoryDiscovery, file-token-storage, skill/subagent managers, installationManager, rulesDiscovery — e.g. installationManager reads a real pre-existing install-id file from$HOME), and the author's three named failures (read-file,zoom-image, memoryextract) do not fail here. The load-bearing invariant was verified instead by A/A: the failure sets are equal modulo 4 parallel-load flakes (2 head-only + 2 base-only inextensionSettings.test.ts/write-file.test.ts, all green in isolation on both arms — extensionSettings 3× green at head) and 5 base-only provider-preset collection errors that are a worktree artifact of this verification (base tree has no builtdist/for the@qwen-code/qwen-code-coreself-import; the vite error message proves the cause).
Not covered
- Live end-to-end cancel against a real provider: harnesses reproduce the wire shapes the SDK throws (real
APIUserAbortError/DOMExceptioninstances through the real compiled generator and retry loop), not the network-side trigger. - CLI package suite not run (core only).
- Per-commit attribution: shallow checkout (
git rev-list HEAD^1..HEAD^2reaches 1 commit; the metadata lists 17). Verified the aggregateHEAD^1..HEADdiff; the reduction's removals verified behaviorally by grep. shouldSuppressErrorLoggingdebug-log path not re-probed live this round; code read shows the openai provider's gate (openaiContentGenerator.ts:62, unchanged by the PR) uses the identicalisAbortError(error) && signal.abortedshape and inherits the util fix — the same known limitation applies there by construction.- The 5 base-only provider-preset failures and the 4 load flakes are artifacts of this verification environment, not properties of the PR or of base.
Methodology
Environment: node:22-bookworm CI container, Node v22.23.2, locked openai@5.11.0 / @anthropic-ai/sdk@0.36.3, head build pre-existing (packages/core/dist + root dist/ bundle). A/B arms: (1) scratch git worktree at HEAD^1 (d96de59) with the PR's three test files copied in for the source-level RED cell (vitest runs source; the PR leaves the lockfile untouched — verified by git diff --name-only = exactly the 5 PR files — so the shared root node_modules is a clean control; a package-local node_modules symlink fixed an ajv@8-vs-@6 hoisting artifact, same as the previous round); (2) a byte copy of head's compiled dist with exactly the two production hunks reverted (diff -rq = 2 files, asserted by harness/00-make-base-dist.mjs) as the dist control. Harnesses (01-abort-matrix.mjs, 02-gate.mjs, 03-retry-loop.mjs, 06-keepnames-probe.mjs) are mock-free with respect to the code under test: real SDK error instances against compiled dist; the real LoggingContentGenerator observed at two real telemetry destinations; the real retryWithBackoff; esbuild probes replicating mainBuild knobs. Mutations (05-mutation-matrix.mjs) applied to scratch copies of the source, suites re-run, restored via git checkout (repo left clean; worktrees removed). One environment quirk discovered: Node's AbortSignal.timeout() timer does not keep a bare process's event loop alive, so the D2 probe needed a loop-keeper in the harness — a harness artifact, not a PR property (production processes always have other loop keepers). Raw logs in logs/ (01–17 plus per-mutant logs), captures in evidence/ (01–05), tallied by harness/90-final-tally.mjs whose 47 checks each encode their expectation (base-arm reds and mutant kills count as passes).
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
|
Triage re-run completed without a new review.
The stage comments above were updated with the latest result. View workflow run. 上方各阶段评论已更新为最新结果。查看工作流运行。 |
wenshao
left a comment
There was a problem hiding this comment.
Test Plan (not a blocker): src/tools/read-file.test.ts — no such file or directory; src/tools/zoom-image.test.ts — no such file or directory; 371 tests green — this review observed 19524, 1124 passed; Tests 19297 passed — this review observed 19524, 1124 passed.
中文说明
Test Plan(非阻断):src/tools/read-file.test.ts — no such file or directory; src/tools/zoom-image.test.ts — no such file or directory; 371 tests green — this review observed 19524, 1124 passed; Tests 19297 passed — this review observed 19524, 1124 passed。
— qwen3.7-max via Qwen Code /review (v0.21.9)
| if (abortSignal?.aborted && isAbortError(error)) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
[Critical] The gate keys only on the state of the caller's signal, never on who fired it. Several in-repo callers compose timeout-based abort signals into model requests — permissions/classifier.ts:170 composes AbortSignal.any([input.signal, AbortSignal.timeout(stage1TimeoutMs)]), memory/relevanceSelector.ts:113 uses AbortSignal.timeout(30_000), and memory/forget.ts:193 uses AbortSignal.timeout(8_000), all routing through runSideQuery → baseLlmClient → req.config.abortSignal. When the timeout fires, Node sets signal.reason to a DOMException named TimeoutError and AbortSignal.any propagates it — but this gate checks only abortSignal?.aborted (true) and isAbortError(error) (true for APIUserAbortError, which the SDK throws for any aborted signal). — Failure scenario: provider latency spike → classifier/memory side query blows its AbortSignal.timeout budget → SDK rejects abort-shaped → gate suppresses the api_error event → at 3 AM the incident shows zero api_errors and a clean model-health chart while background LLM work silently fails. Probe-verified: with a TimeoutError-reason signal and APIUserAbortError, logApiError is called 0 times; adding signal.reason.name !== 'TimeoutError' flips the probe to pass with all 8 existing cancel tests still green.
| if (abortSignal?.aborted && isAbortError(error)) { | |
| return; | |
| } | |
| const reason: unknown = abortSignal?.reason; | |
| const isTimeoutAbort = reason instanceof Error && reason.name === 'TimeoutError'; | |
| if (abortSignal?.aborted && !isTimeoutAbort && isAbortError(error)) { | |
| return; | |
| } |
Node sets signal.reason to a DOMException named TimeoutError for AbortSignal.timeout(), and AbortSignal.any propagates the firing source's reason; please also add a test pinning the timeout case.
中文说明
[Critical] 该门控只依赖调用方 signal 的状态,从不区分是谁触发了它。仓库中多个调用方会向模型请求传入基于超时的 abort signal——permissions/classifier.ts:170 组合 AbortSignal.any([input.signal, AbortSignal.timeout(stage1TimeoutMs)]),memory/relevanceSelector.ts:113 使用 AbortSignal.timeout(30_000),memory/forget.ts:193 使用 AbortSignal.timeout(8_000),均经 runSideQuery → baseLlmClient → req.config.abortSignal 传入。超时触发时,Node 会把 signal.reason 设为名为 TimeoutError 的 DOMException,AbortSignal.any 会传播该 reason——但此门控仅检查 abortSignal?.aborted(true)和 isAbortError(error)(对 APIUserAbortError 为 true,SDK 对任何已中止 signal 都会抛出)。——失败场景:provider 延迟尖峰 → classifier/memory 侧查询超出超时预算 → SDK 以中止形态拒绝 → 门控抑制 api_error 事件 → 凌晨 3 点的故障中 api_error 为零、模型健康图表干净,后台 LLM 任务悄无声息地失败。已用探针验证:TimeoutError reason 的 signal 加 APIUserAbortError 时,logApiError 被调用 0 次;增加 signal.reason.name !== 'TimeoutError' 后探针翻转为通过,全部 8 个已有取消测试保持绿色。
— qwen3.7-max via Qwen Code /review (v0.21.9)
|
On R1-1 — I'm not applying this one, because it's the trade-off @wenshao explicitly accepted when he asked for the split, and the suggested fix is the polarity he argued against. His review: "under this version internal timeouts are still suppressed, but that's the status quo, not a regression." That is exactly R1-1 — the gate can't tell a user cancel from an internal deadline aborting the same request. It's deliberate here, and the PR body now says so under Scope rather than leaving it implicit. The suggestion is The follow-up inverts it: report by default, user cancels opt in. Only the small closed set of cancel producers is tagged (the TUI turn cancel, which was a bare abort, and the ACP session, which already used That work is implemented and verified, waiting on this PR to land: 10 files, +254/−35, with a test pinning the exact R1-1 case (an internal deadline aborting the request is reported, not suppressed). It also documents two things it does not close — the daemon prompt deadline still launders through the cancel tag at the ACP boundary, and the cancel-producer set wants a wider audit for secondary aborts. Also fixed here: the test-plan numbers in the description were stale from before the split. The body now matches this PR — 19575 passed, 3 failed, with each failing file byte-identical to origin/main (two local workspace-trust expectations, one flaky under parallel load that passes in isolation). @wenshao — no code change on this PR from this round. If you'd rather the |
Local real-stack verification (maintainer run)I built this PR and its merge-base locally and drove the real TUI end-to-end — real bundled Setup. base = merge-base Results1. The #8398/#8356 repro — ESC during request setup — flips as claimed. With the mock holding the response head and ESC pressed ~1.5 s in, the OpenAI SDK throws
2. Negative control — a genuine failure is still reported. HTTP 500 from the mock on the PR bundle: 3. Mid-stream ESC: no api_error on either side. Cancelling at ~chunk 14 of a live stream produced 4. No zombie retry on the wire. Mock ledger over a 25 s post-ESC quiet window: 5. Unit A/B is RED→GREEN. PR's new test files on unmodified base source: 3 files, 7 failed / 141 passed — the failures are precisely the seven new assertions (both SDK 6. Class-name matching holds in the production bundle. The E2E runs exercise the bundled 7. UX unchanged. Same "Request cancelled." flow, prompt restored, single ESC — identical on both builds: Not covered here: the user-cancel vs internal-deadline ambiguity of the gate — that is the known limitation this PR carries deliberately and the follow-up PR's subject. Verdict: behaviour verified on the real stack; from my side this is good to merge as the reduced bug fix. Evidence images live on 中文版本(Chinese version)本地真实栈验证(维护者执行)本地构建了本 PR 与其 merge-base,用真实打包产物 环境: base = merge-base 结果
未覆盖: 门控无法区分用户取消与内部超时——即本 PR 有意保留的已知限制,属后续 PR 范畴。 结论: 真实栈行为验证通过;就本人而言,该缩减后的 bug fix 可以合入。 |
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅
|
Released in v0.21.11. |














What this PR does
Fixes #8398 — a user cancel on the OpenAI-compatible path is misclassified and reported as an API error.
isAbortErrorrecognizes the OpenAI SDK'sAPIUserAbortError. That class sets no.name(it stays'Error') and carries noABORT_ERRcode, so both existing checks missed it. It is matched by class name, which keeps this provider-agnostic util free of an SDK import.safelyLogApiErrorskips theapi_errorevent on a user cancel. It emittedApiErrorEventunconditionally, so cancelling produced aqwen-code.api_errorwitherror_type=APIUserAbortError— the noise reported in Bug: after APIUserAbortError, subsequent turns are not written to the local session transcript #8356. The span still records the cancellation, so nothing is lost.Why it's needed
LoggingContentGenerator.safelyLogApiError, which was not gated at all. Thanks to @wenshao and @yiliang114 for verifying the util fix and showing it did not close this path on its own.kind:'unknown'instead of'abort', missing the no-retry short-circuit.shouldSuppressErrorLogginggates onisAbortError, so the cancel was logged as an API error there too.geminiChatand the MCP/artifact tools also route aborts throughisAbortError, so the util fix corrects them as well.Scope
Per @wenshao's review, this PR is only the bug fix. The cross-cutting invariant that grew here across earlier rounds — a shared user-cancel predicate plus tagging every internal deadline — is split into a separate PR so its polarity can be settled before it merges. That work is implemented and will be opened once this lands.
Known limitation, carried deliberately: the gate is
abortSignal?.aborted && isAbortError(error), which cannot tell a user cancel from an internal deadline aborting the same request, so a timed-out internal side query stays suppressed. That is the pre-existing behaviour, not a regression introduced here, and it is what the follow-up PR fixes.Reviewer Test Plan
Result on this branch: 19575 passed, 3 failed, 11 skipped.
All three failures are unrelated to this PR, and each file is byte-identical to origin/main on this branch:
Every suite this PR touches is green: the changed and reverted areas together are 43 files, all passing.
tsc --noEmitis clean for both core and cli, and lint is clean.Evidence
RED→GREEN on the fix itself: on unmodified source
isAbortErrorreturnsfalsefor the SDK's user-abort error and the retry classifier labels itkind:'unknown'; both flip with the fix applied.The gate is covered for the shapes a cancel actually takes — the SDK error on the non-stream path, on stream setup, and mid-stream, plus the DOMException
AbortErrorthe Google GenAI SSE reader propagates — and for the case that must still be reported: a genuine failure that races a cancel.Risk & Scope
instanceof, to keep the provider-agnostic util free of an SDK import. This relies on the CLI bundle'skeepNames. Happy to switch toinstanceofat the provider boundary — @wenshao suggested this and I agree; it is a small independent change that can land here or in the follow-up, your call.isAbortError's signature is unchanged; it only starts returningtruefor a genuine user abort it previously missed.Refs, notCloses.Linked Issues
Refs #8398