Skip to content

fix(web-shell): quiet background task polling failures - #7923

Merged
wenshao merged 6 commits into
QwenLM:mainfrom
han-dreamer:fix/web-shell-silent-task-polling
Aug 1, 2026
Merged

fix(web-shell): quiet background task polling failures#7923
wenshao merged 6 commits into
QwenLM:mainfrom
han-dreamer:fix/web-shell-silent-task-polling

Conversation

@han-dreamer

Copy link
Copy Markdown
Contributor

What this PR does

Adds a silent mode for getTasks and uses it from the web-shell background task poller. In silent mode, transient task refresh failures such as aborts, timeouts, network fetch failures, and retryable HTTP responses are rethrown without dispatching user-facing notices, while hard failures still dispatch at most one notice per error identity so they are not lost or repeated every poll.

Why it's needed

useBackgroundTasks refreshes tasks on a 3-second interval, but getTasks previously routed every failure through dispatchActionError, which could produce repeated error notices during background polling. This keeps foreground getTasks() calls reporting normally while making the polling path quiet for retryable failures and non-spammy for hard failures.

Reviewer Test Plan

How to verify

Run the focused webui action tests and confirm default getTasks() failures still dispatch notices, silent transient failures do not dispatch notices, silent retryable HTTP failures do not dispatch notices, and silent hard failures dispatch only once.

Run the focused web-shell hook test and confirm useBackgroundTasks passes { silent: true } for immediate refreshes, session-switch refreshes, and interval refreshes.

Commands run locally: npm test --workspace=@qwen-code/webui -- src/daemon/session/actions.test.ts, npm test --workspace=@qwen-code/web-shell -- client/hooks/useBackgroundTasks.test.tsx, npm run typecheck --workspace=@qwen-code/webui, npx prettier --check packages/webui/src/daemon/session/types.ts packages/webui/src/daemon/session/actions.ts packages/webui/src/daemon/session/actions.test.ts packages/web-shell/client/hooks/useBackgroundTasks.ts packages/web-shell/client/hooks/useBackgroundTasks.test.tsx.

Evidence (Before & After)

N/A — non-visual action and hook behavior covered by unit tests.

Tested on

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

Environment (optional)

Windows local workspace with Vitest, TypeScript, and Prettier. I also rebuilt @qwen-code/webui locally before checking the web-shell type surface because web-shell typecheck reads the local webui dist declarations.

Risk & Scope

  • Main risk or tradeoff: the transient classifier is intentionally small and follows the existing retryable status precedent of 5xx, 408, and 429; unusual network errors may still be treated as hard and surfaced once.
  • Not validated / out of scope: no manual TUI recording; getStats is left unchanged because triage identified it as user-initiated rather than a background poll.
  • Breaking changes / migration notes: none; getTasks() remains backward compatible because the new options argument is optional.

Linked Issues

Refs #7834.

中文说明

What this PR does

getTasks 增加 silent 模式,并让 web-shell 的后台任务轮询使用这个模式。在 silent 模式下,abort、timeout、网络 fetch 失败、可重试 HTTP 响应等瞬时任务刷新失败会原样抛出但不派发用户可见 notice;硬性失败仍会按错误身份最多派发一次 notice,避免错误完全丢失,也避免每次轮询重复弹出。

Why it's needed

useBackgroundTasks 每 3 秒刷新一次任务,但此前 getTasks 的每次失败都会经过 dispatchActionError,后台轮询失败时可能产生重复 error notice。这个改动保持前台 getTasks() 调用正常报错,同时让轮询路径对可重试失败保持安静,并让硬性失败不再刷屏。

Reviewer Test Plan

How to verify

运行聚焦的 webui action 测试,确认默认 getTasks() 失败仍会派发 notice,silent 瞬时失败不会派发 notice,silent 可重试 HTTP 失败不会派发 notice,silent 硬性失败只派发一次 notice。

运行聚焦的 web-shell hook 测试,确认 useBackgroundTasks 在立即刷新、session 切换刷新、interval 刷新时都会传入 { silent: true }

本地运行命令:npm test --workspace=@qwen-code/webui -- src/daemon/session/actions.test.tsnpm test --workspace=@qwen-code/web-shell -- client/hooks/useBackgroundTasks.test.tsxnpm run typecheck --workspace=@qwen-code/webuinpx prettier --check packages/webui/src/daemon/session/types.ts packages/webui/src/daemon/session/actions.ts packages/webui/src/daemon/session/actions.test.ts packages/web-shell/client/hooks/useBackgroundTasks.ts packages/web-shell/client/hooks/useBackgroundTasks.test.tsx

Evidence (Before & After)

N/A —— 这是非视觉的 action 和 hook 行为变更,已由单元测试覆盖。

Tested on

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

Environment (optional)

Windows 本地工作区,使用 Vitest、TypeScript 和 Prettier。我还在本地重新构建了 @qwen-code/webui 后再检查 web-shell 的类型表面,因为 web-shell typecheck 会读取本地 webui dist 声明。

Risk & Scope

  • Main risk or tradeoff: 瞬时错误分类器刻意保持较小范围,并沿用现有 5xx、408、429 可重试状态的先例;少见的网络错误仍可能被当作硬性失败并最多提示一次。
  • Not validated / out of scope: 没有做手动 TUI 录屏;getStats 保持不变,因为 triage 已指出它是用户主动触发,不是后台轮询。
  • Breaking changes / migration notes: 无;getTasks() 保持向后兼容,因为新增 options 参数是可选的。

Linked Issues

Refs #7834.

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on 121eca7 and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— 121eca7 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

  • Template: complete ✓ — all required sections present, and Evidence (Before & After) is correctly marked N/A for a non-visual action/hook change.
  • Problem: real and observed, not theoretical. This is the direct follow-up tracked in web-shell: silent background polls should distinguish transient vs hard errors; extend silent option to getStats #7834 (a welcome-pr, P3 issue) with a clear lineage: web-shell: artifact panel spams 'Load artifacts failed: Failed to fetch' on automatic refresh #7427 reported error-toast spam from background refreshes, fix(web-shell): polish embedded shell interactions #7477 silenced loadArtifacts, and web-shell: silent background polls should distinguish transient vs hard errors; extend silent option to getStats #7834 asked for a transient-vs-hard classifier plus extending the silent treatment to the other polls. The always-on task poller in useBackgroundTasks (3s setInterval) currently routes every failed poll through dispatchActionErroraddNotice, which is exactly the spam source.
  • Direction: aligned. The change implements the issue's "Suggested shape" almost verbatim — transient (abort/timeout/network/retryable HTTP) + silent → suppress; hard + silent → surface once per error identity. Worth calling out: the author correctly investigated and found getTasks (via useBackgroundTasks) is the genuine always-on background poller, while getStats only runs from the user-typed /stats command — so diverging from the issue's literal "extend to getStats" wording is the right call, not a miss.
  • Size: not core infrastructure (a web-shell client hook plus webui daemon-session application code). ~104 production lines (actions.ts 96, types.ts 6, useBackgroundTasks.ts 2) and ~83 test lines. It does span two packages, but only to couple the hook with the action it calls — well under any threshold and no maintainer-awareness flag needed.
  • Approach: scope feels right and minimal. Only the always-on poller is made silent; foreground getTasks callers (App.tsx and the tasks-panel active-view refresh, which has its own inline error state) keep normal reporting, and the new options argument is optional so the signature stays backward compatible. One thing worth thinking about (non-blocking, raised again in code review): the "notice once" set lives for the whole actions object and is never reset on session switch, so a hard error identity is remembered across sessions.
  • Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths.

Moving on to code review. 🔍

中文说明

感谢贡献!

  • 模板: 完整 ✓ —— 所有必填段落齐全,非视觉的 action/hook 变更也正确地把 Evidence (Before & After) 标为 N/A。
  • 问题: 真实且已观测,不是理论性问题。这是 web-shell: silent background polls should distinguish transient vs hard errors; extend silent option to getStats #7834welcome-pr、P3 issue)直接跟踪的后续工作,脉络清晰:web-shell: artifact panel spams 'Load artifacts failed: Failed to fetch' on automatic refresh #7427 报告后台刷新产生 error-toast 刷屏,fix(web-shell): polish embedded shell interactions #7477loadArtifacts 静默,web-shell: silent background polls should distinguish transient vs hard errors; extend silent option to getStats #7834 要求增加"瞬时 vs 硬性"错误分类器并把静默处理扩展到其他轮询。useBackgroundTasks 里那个常驻任务轮询(3 秒 setInterval)目前会把每次失败都经 dispatchActionErroraddNotice 弹出,正是刷屏来源。
  • 方向: 对齐。改动几乎逐条实现了 issue 的"Suggested shape"——瞬时(abort/timeout/网络/可重试 HTTP)+ silent → 抑制;硬性 + silent → 按错误身份最多提示一次。特别值得肯定:作者实际排查后确认 getTasks(经由 useBackgroundTasks)才是真正的常驻后台轮询,而 getStats 只在用户输入 /stats 命令时触发——所以没有照搬 issue 字面上"扩展到 getStats"的说法,而是做了正确取舍,这不是遗漏。
  • 规模: 不属于核心基础设施(一个 web-shell client hook 加 webui daemon-session 应用层代码)。约 104 行生产代码(actions.ts 96、types.ts 6、useBackgroundTasks.ts 2)和约 83 行测试。确实跨了两个包,但只是把 hook 和它调用的 action 耦合起来——远低于任何阈值,无需维护者关注标记。
  • 方案: 范围合理且克制。只把常驻轮询设为静默;前台的 getTasks 调用方(App.tsx 以及任务面板的活跃视图刷新——后者自带内联错误状态)仍正常报错,新增的 options 参数是可选的,签名保持向后兼容。有一点值得思考(非阻塞,代码审查会再提):"只提示一次"的集合生命周期覆盖整个 actions 对象,且在切换 session 时不会重置,因此某个硬性错误身份会跨 session 被记住。
  • 风险: 无升级风险信号——改动文件均未命中与 revert 相关的高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 121eca78b689d960f54a9e0ec530ad0d34900764 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Code review

I formed my own baseline before reading the diff: add an optional { silent?: boolean } to getTasks, classify failures in the catch (abort/timeout/network/retryable-HTTP → transient, suppress; everything else → hard, surface once per identity), flip the background poller to silent: true, and cover each path with tests. The PR does essentially this, and does it cleanly. A few things I verified rather than took on faith:

  • The inline session check is deliberate, not a drive-by. Replacing requireSessionForAction with an inline throw new Error('Daemon session is not connected') inside the try is necessary — requireSessionForAction dispatches its notice unconditionally and can't take the silent option, so leaving it would spam a "not connected" notice on every poll. Routed through the classifier, "not connected" is a hard failure that surfaces once. The foreground path is unchanged: the resulting notice message (Get tasks failed: Daemon session is not connected) is byte-identical to before.
  • The dedupe in dispatchActionError is backward compatible. With no opts, noticeKey is undefined so addNotice always fires (existing behavior for every other caller). Only the silent path passes a key + set, giving "at most one notice per ${operation}:${message}". The transient rethrow happens before dispatchActionError, so transient errors never touch the set.
  • The classifier matches house precedent. status >= 500 || 408 || 429 mirrors the existing inline retryable logic in DaemonSessionProvider.tsx. There's no shared helper to reuse (the loadArtifacts precedent from fix(web-shell): polish embedded shell interactions #7477 just throws raw with no classifier), so adding the small isTransientActionError/extractHttpStatus helpers here is the right move, and the DaemonHttpError import from @qwen-code/sdk/daemon is valid. The string-matching fallback is brittle by nature, but the author owns that tradeoff and the failure mode is safe — a misclassified transient error shows one notice, never a return to spam.
  • Scoping is correct. Only the always-on useBackgroundTasks poller is silenced. The tasks-panel refresh in TasksStatusMessage only runs while the panel is open and has its own inline error state, and the other getTasks callers stay foreground — all correctly left non-silent.

One non-blocking reservation: silentHardFailureNoticeKeys is created once per actions object and is never cleared — clearActiveSessionState() doesn't reset it. The key has no session component (load_tasks:<message>), so a hard error seen in session A is remembered after switching to session B and won't re-notify there. The issue did sanction "log once per error identity," so this is defensible as-is; clearing the set in clearActiveSessionState() would give each session a fresh "once" if you want tighter semantics. Either way, not a merge blocker.

No correctness bugs, security issues, or convention violations (ESM, unknown + type guards rather than any, collocated tests, no stray comments). Tests cover all four action paths plus the hook passing { silent: true } on immediate, session-switch, and interval refreshes.

Testing

This is an unattended CI run, so I did not build or execute the PR — the signal below is the PR's own CI, read via the API. The primary unit suite (Test (ubuntu-latest, Node 22.x)) and the web-shell visual capture are still in progress on the reviewed commit; no check has failed. The table is wrapped for the finalize workflow to update in place once CI settles, and any approval is deferred until then (see my final comment).

Final CI results for 121eca7 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Real-scenario (TUI) testing: N/A — this is non-visual notice-dispatch logic, fully exercised by the unit tests above; there is no on-screen behavior to capture. The sandboxed @qwen-code /tmux and @qwen-code /verify lanes execute the PR author's code and therefore require write access, which this fork contributor does not have, so they are not triggerable here; if a maintainer wants live confirmation, checking out the branch and running the two focused suites named in the PR description is the direct path.

中文说明

代码审查

我在看 diff 之前先独立给出了自己的方案:给 getTasks 加可选的 { silent?: boolean },在 catch 里对错误分类(abort/timeout/网络/可重试 HTTP → 瞬时,抑制;其余 → 硬性,按错误身份最多提示一次),把后台轮询切到 silent: true,并为每条路径补测试。PR 基本就是这么做的,而且做得干净。有几处我是验证过而非想当然:

  • 内联 session 检查是刻意的,不是顺手改。 用内联的 throw new Error('Daemon session is not connected')(放在 try 内)替换 requireSessionForAction 是必要的——requireSessionForAction 会无条件派发 notice 且无法接收 silent 选项,保留它会导致每次轮询都弹"not connected"。改走分类器后,"not connected" 作为硬性失败只提示一次。前台路径不变:最终 notice 文案(Get tasks failed: Daemon session is not connected)与之前逐字一致。
  • dispatchActionError 里的去重是向后兼容的。 不传 optsnoticeKey 为 undefined,addNotice 照常触发(其他所有调用方的现有行为)。只有 silent 路径会传入 key + 集合,实现"每个 ${operation}:${message} 最多一次 notice"。瞬时错误在进 dispatchActionError 之前就已 rethrow,不会污染集合。
  • 分类器与项目先例一致。 status >= 500 || 408 || 429DaemonSessionProvider.tsx 里现有的内联可重试逻辑一致。没有可复用的共享 helper(fix(web-shell): polish embedded shell interactions #7477loadArtifacts 先例只是原样抛出、没有分类器),所以在此处新增小巧的 isTransientActionError/extractHttpStatus 是合理选择,从 @qwen-code/sdk/daemon 引入 DaemonHttpError 也有效。字符串匹配兜底天然脆弱,但作者已明确承担这一取舍,且失败模式安全——误判的瞬时错误只会弹一次 notice,不会回到刷屏。
  • 范围正确。 只把常驻的 useBackgroundTasks 轮询设为静默。TasksStatusMessage 里的任务面板刷新只在面板打开时运行且自带内联错误状态,其余 getTasks 调用方仍是前台——都正确地保持非静默。

一个非阻塞的保留点: silentHardFailureNoticeKeys 在每个 actions 对象创建时生成一次,且从不清空——clearActiveSessionState() 不会重置它。key 不含 session 维度(load_tasks:<message>),所以在 session A 出现的硬性错误,切到 session B 后仍被记住、不会再次提示。issue 确实认可"按错误身份只记录一次",因此现状站得住脚;如果想要更严格的语义,可以在 clearActiveSessionState() 里清空集合,让每个 session 都有自己的一次提示。无论哪种,都不是合并阻塞项。

无正确性 bug、安全问题或规范违规(ESM、用 unknown + 类型守卫而非 any、测试就近放置、无多余注释)。测试覆盖了 action 的全部四条路径,以及 hook 在立即刷新、session 切换刷新、interval 刷新时都传入 { silent: true }

测试

这是无人值守的 CI 运行,因此我没有构建或执行 PR——下方信号来自 PR 自身的 CI,通过 API 读取。主单元测试套件(Test (ubuntu-latest, Node 22.x))和 web-shell 视觉采集在受审 commit 上仍在进行;目前没有任何 check 失败。表格已用标记包裹,待 CI 落定后由 finalize workflow 原地更新;任何批准都会推迟到那时(见我最后的评论)。

真实场景(TUI)测试:N/A——这是非视觉的 notice 派发逻辑,已由上面的单元测试完整覆盖,没有可截屏的界面行为。沙箱化的 @qwen-code /tmux@qwen-code /verify 通道会执行 PR 作者的代码,因而需要写权限,而该 fork 贡献者没有,所以这里无法触发;如果维护者想做现场确认,最直接的方式是检出分支并运行 PR 描述里点名的两个聚焦测试套件。

Qwen Code · qwen3.8-max-preview

Reviewed at 121eca78b689d960f54a9e0ec530ad0d34900764 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — a small, well-tested fix that does exactly what the tracking issue asked; the only reservation is a minor design choice, not a defect.

Stepping back: this is the kind of PR that's easy to approve for the right reasons. The problem is verified independently of the PR's own framing — #7834 is a maintainer-triaged welcome-pr follow-up to a real toast-spam report, and I confirmed the mechanism in code (the always-on 3s poller funnels every failure into addNotice). My independent proposal and the PR converged, and I didn't find a materially simpler path it missed; if anything the author showed good judgment by checking which poll actually runs in the background (getTasks) rather than mechanically following the issue's mention of getStats, which turns out to be user-initiated.

The code is straightforward and isn't trying too hard — a small classifier, a backward-compatible dedupe hook in dispatchActionError, and a one-line flip at the single call site that needs it. Every edit earns its place; there's no drive-by churn. The one thing I'd want the author to think about is the never-reset silentHardFailureNoticeKeys set carrying error identities across session switches — defensible given the issue's "log once per identity" wording, and easy to tighten later if it ever matters. I'm approving with that noted, not blocking on it.

I could not run the change myself (unattended CI never executes PR code), so the live signal is the PR's own CI plus its focused unit tests, which cover all four dispatch paths and the hook wiring. The primary unit suite is still running on the reviewed commit with nothing red, so approval is deferred until CI lands green on 121eca78b689d960f54a9e0ec530ad0d34900764 — the finalize step will post the commit-pinned approval automatically, and will withhold it if anything fails or the head moves.

中文说明

置信度:4/5 —— 一个小巧、测试充分的修复,恰好做了跟踪 issue 要求的事;唯一的保留是一个轻微的设计取舍,而非缺陷。

退一步看:这是一个容易"因为正确的理由"而批准的 PR。问题独立于 PR 自身的叙述得到了验证——#7834 是维护者 triage 过的 welcome-pr 后续,源自一次真实的 toast 刷屏报告;我也在代码里确认了机制(常驻的 3 秒轮询把每次失败都送进 addNotice)。我的独立方案与 PR 收敛,也没找到它遗漏的更简路径;如果说有什么,是作者展现了良好判断——核实了哪个轮询真正在后台运行(getTasks),而不是机械地照搬 issue 提到的 getStats(后者其实是用户主动触发)。

代码直白、没有用力过猛——一个小分类器、dispatchActionError 里一个向后兼容的去重挂钩,以及在唯一需要的调用点上一行切换。每处改动都物有所值,没有顺手乱改。唯一希望作者再想想的,是从不清空的 silentHardFailureNoticeKeys 集合会把错误身份带到 session 切换之后——鉴于 issue 认可"按身份只记录一次",这站得住脚,将来若真有影响也容易收紧。我会带着这一点批准,而不是据此阻塞。

我无法亲自运行该改动(无人值守 CI 从不执行 PR 代码),所以现场信号来自 PR 自身的 CI 及其聚焦单元测试——后者覆盖了全部四条派发路径和 hook 接线。主单元测试套件在受审 commit 上仍在运行且无红,因此批准推迟到 CI 在 121eca78b689d960f54a9e0ec530ad0d34900764 上转绿之后——finalize 步骤会自动发布与该 commit 绑定的批准;若有任何失败或 head 移动,则会保留不批。

Qwen Code · qwen3.8-max-preview

Reviewed at 121eca78b689d960f54a9e0ec530ad0d34900764 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

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

Screenshots · before / after

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

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

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

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

Qwen Code · web-shell visuals

@qwen-code-ci-bot qwen-code-ci-bot 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, looks ready to ship — CI landed green after the review. ✅

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

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.7-max via Qwen Code /review

setAttachSessionNonce,
setNewSessionNonce,
}: CreateDaemonSessionActionsArgs): DaemonSessionActions {
const silentHardFailureNoticeKeys = new Set<string>();

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.

[Suggestion] silentHardFailureNoticeKeys is never cleared on session change, reconnection, or clear — it grows monotonically for the provider's lifetime, permanently suppressing repeat notices of the same hard error across different sessions.

Failure scenario: a user in session A encounters a non-transient getTasks error (e.g. "Malformed response"). The notice fires once and the key "load_tasks:Malformed response" is added to the set. The user then switches to session B or the daemon reconnects — clearActiveSessionState() runs but does not touch this set. Session B encounters the same error, but dispatchActionError sees the key already present and silently suppresses the notice. The user has no UI-visible indication the error persists.

Suggested change
const silentHardFailureNoticeKeys = new Set<string>();
const silentHardFailureNoticeKeys = new Set<string>();
// NOTE: consider clearing in clearActiveSessionState() so a new session
// gets a fresh dedup window.
中文说明

[Suggestion] silentHardFailureNoticeKeys 在 session 切换、重连或清理时从未被清空——它随着 provider 的生命周期单调增长,在不同 session 间永久压制相同硬性错误的重复通知。

失败场景:用户在 session A 遇到非瞬时 getTasks 错误(如 "Malformed response"),通知触发一次并将 key 加入集合。随后用户切换到 session B 或 daemon 重连——clearActiveSessionState() 执行但不会清空此集合。Session B 遇到同样的错误,但 dispatchActionError 发现 key 已存在,静默压制通知。用户看不到任何 UI 可见的错误提示。

建议在 clearActiveSessionState() 中添加 silentHardFailureNoticeKeys.clear()

— qwen3.7-max via Qwen Code /review

Comment on lines +1500 to +1506
function extractHttpStatus(error: unknown): number | undefined {
if (error instanceof DaemonHttpError) return error.status;
if (isRecord(error) && typeof error['status'] === 'number') {
return error['status'];
}
return undefined;
}

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.

[Suggestion] extractHttpStatus and isRecord are duplicated character-for-character from DaemonSessionProvider.tsx (lines 3232–3242), a file in the same directory.

Concrete cost: two identical private copies of the same helper in adjacent files. A future change to the error-shape contract (e.g. supporting a statusCode field or a new SDK error class) must be made in both places independently, and a miss in one silently diverges the HTTP-status detection behavior between provider-level error handling and action-level error handling.

Suggested fix: export both helpers from a shared module in the same directory (e.g. error-utils.ts) and import them where needed.

中文说明

[Suggestion] extractHttpStatusisRecord 与同目录的 DaemonSessionProvider.tsx(第 3232–3242 行)中的实现完全一致。

具体成本:相邻文件中存在两份相同的私有工具函数副本。未来对错误形状的修改(如支持 statusCode 字段或新的 SDK 错误类)必须在两处独立修改,遗漏一处会导致 provider 层和 action 层的 HTTP 状态检测行为静默分歧。

建议将两个工具函数提取到同目录的共享模块中(如 error-utils.ts),并在需要的地方导入。

— qwen3.7-max via Qwen Code /review

@han-dreamer

Copy link
Copy Markdown
Contributor Author

Addressed the latest review suggestions in 34d573a. clearActiveSessionState() now resets the silent hard-failure dedupe set, with a regression test covering the same hard getTasks({ silent: true }) failure after clearSession(). I also extracted the duplicated extractHttpStatus / isRecord helpers into httpErrors.ts and reused them from both action-level and provider-level error handling.

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

⚠️ This run could not certify that any of this diff was reviewed. Suggestions are inline. Not reviewed: coverage — no plan was given, so this run cannot show that any of the diff was read.

— qwen3.7-max via Qwen Code /review

Comment on lines +1483 to +1488
function isTransientActionError(error: unknown): boolean {
if (isAbortError(error)) return true;
const status = extractHttpStatus(error);
if (status !== undefined) {
return status >= 500 || status === 408 || status === 429;
}

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.

[Suggestion] isTransientActionError has several untested branches: status codes 408 and 429, the isAbortError branch, and message patterns 'timed out', 'network error', and 'networkerror'. Only HTTP 500 and 'failed to fetch' are exercised by the current test suite.

Failure scenario: a future edit that inverts the >= 500 comparison or drops 429 from the transient set would not be caught — the existing test covering only HTTP 500 would still pass, and the regression would ship silently.

Consider adding tests for: (a) DaemonHttpError(408) with silent: true → no notice; (b) DaemonHttpError(429) with silent: true → no notice; (c) an abort-style DOMException('AbortError') with silent: true → no notice.

中文说明

[Suggestion] isTransientActionError 有多个未经测试的分支:状态码 408 和 429、isAbortError 分支,以及消息模式 'timed out''network error''networkerror'。当前测试套件仅覆盖了 HTTP 500 和 'failed to fetch'

失败场景:未来的修改如果反转 >= 500 比较或从瞬时集合中移除 429,将不会被捕获——现有的仅覆盖 HTTP 500 的测试仍会通过,回归将静默发布。

建议添加测试:(a) DaemonHttpError(408) + silent: true → 不派发通知;(b) DaemonHttpError(429) + silent: true → 不派发通知;(c) DOMException('AbortError') + silent: true → 不派发通知。

— qwen3.7-max via Qwen Code /review

if (isAbortError(error)) return true;
const status = extractHttpStatus(error);
if (status !== undefined) {
return status >= 500 || status === 408 || status === 429;

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.

[Suggestion] No test positively asserts that a non-retryable HTTP status (e.g., 401, 403, 404) in silent mode IS surfaced as a notice. The hard-failure test uses a generic Error('Malformed response'), never a DaemonHttpError with a 4xx status.

Failure scenario: if the comparison were accidentally broadened to status >= 400, a DaemonHttpError(401, …) would be classified as transient and silently swallowed by the background poller — the user would see no indication that authentication failed, while the UI continued showing stale task data.

Consider adding a test: DaemonHttpError(403) with silent: trueaddNotice IS called once, and the error propagates.

中文说明

[Suggestion] 没有测试正面断言不可重试的 HTTP 状态码(如 401、403、404)在 silent 模式下作为通知派发。硬性失败测试使用的是普通的 Error('Malformed response'),从未使用带 4xx 状态码的 DaemonHttpError

失败场景:如果比较被意外扩大为 status >= 400DaemonHttpError(401, …) 将被归类为瞬时错误并被后台轮询静默吞掉——用户看不到任何认证失败的提示,而 UI 继续显示过期的任务数据。

建议添加测试:DaemonHttpError(403) + silent: trueaddNotice 被调用一次,且错误继续传播。

— qwen3.7-max via Qwen Code /review

Comment on lines +9 to +15
export function extractHttpStatus(error: unknown): number | undefined {
if (error instanceof DaemonHttpError) return error.status;
if (isRecord(error) && typeof error['status'] === 'number') {
return error['status'];
}
return undefined;
}

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.

[Suggestion] The extracted httpErrors.ts module has no dedicated test file. The DaemonHttpError branch of extractHttpStatus is exercised indirectly via actions.test.ts, but the duck-typed isRecord + status branch (for plain { status: 500 } errors) is never exercised anywhere.

Failure scenario: a refactor that renames the status property check or drops the duck-type branch would not be caught — callers throwing a non-DaemonHttpError object with a status field would stop being classified correctly.

中文说明

[Suggestion] 提取的 httpErrors.ts 模块没有专门的测试文件。extractHttpStatusDaemonHttpError 分支通过 actions.test.ts 间接覆盖,但鸭子类型化的 isRecord + status 分支(用于处理普通 { status: 500 } 错误对象)从未被任何测试覆盖。

失败场景:如果重构将 status 属性检查重命名或移除了鸭子类型分支,将不会被捕获——抛出非 DaemonHttpError 但带有 status 字段的错误对象的调用方将不再被正确分类。

— qwen3.7-max via Qwen Code /review

@han-dreamer

Copy link
Copy Markdown
Contributor Author

Addressed the latest coverage suggestions in 484d733. Added focused tests for retryable HTTP 408/429, abort-style silent task failures, transient timeout/network message matching, hard HTTP 403 notice behavior in silent mode, and dedicated httpErrors helper coverage for DaemonHttpError, duck-typed { status }, invalid status values, and record detection.

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

Reviewed — no blockers. Suggestions are inline.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +1135 to +1137
const session = sessionRef.current;
if (!session) {
throw new Error('Daemon session is not connected');

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.

[Suggestion] This new inline no-session guard (which replaces requireSessionForAction) has no test coverage — the only "not connected" test in actions.test.ts targets attachSession. — Concrete cost: this path is a behaviour change (when silent, the not-connected error now routes through dispatchActionError and is deduped to a single notice instead of noticing on every call), and if a future edit makes isTransientActionError match this message or reorders the guard, a permanently disconnected session would surface zero user feedback and nothing in the suite would fail. Consider adding a case with sessionRef.current undefined that asserts await expect(actions.getTasks({ silent: true })).rejects.toThrow('Daemon session is not connected') and addNotice called once across two silent calls, plus a non-silent variant.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +1141 to +1143
if (opts?.silent && isTransientActionError(error)) {
throw error;
}

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.

[Suggestion] A persistently transient-classified failure is silenced indefinitely with no escalating signal. — Failure scenario: a daemon that consistently exceeds the action timeout (or sits behind a broken proxy) rejects every 3s poll with e.g. Get tasks timed out after 30000ms; each failure is transient, so it is rethrown silently and useBackgroundTasks only console.warns — without session id or failure count. The tasks panel freezes with zero UI feedback, indistinguishable from "no active tasks". Consider tracking consecutive transient failures and enriching the warn with sessionId/a counter, and/or after N consecutive transient failures surfacing a single notice or downgrading to a non-silent getTasks() so a persistent condition eventually becomes visible.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines 150 to +151
function clearActiveSessionState() {
silentHardFailureNoticeKeys.clear();

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.

[Suggestion] silentHardFailureNoticeKeys is cleared only on clearSession()/newSession(), never on a successful poll. — Failure scenario: a hard failure (e.g. HTTP 403 Forbidden) dispatches a notice once and stores load_tasks:Forbidden; the cause is then fixed and polling stays healthy long enough for the user to dismiss the stale notice; when the same failure later recurs, the key is still in the set, so the notice is suppressed and useBackgroundTasks only console.warns — the panel silently stops updating with zero UI feedback. Since only getTasks uses this set, consider clearing it on a successful getTasks resolve so a failure recurring after recovery is surfaced again.

— qwen3.8-max-preview via Qwen Code /review

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

…no-session guard

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

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge summary — PR #7923

Root cause

Main's PR #7929 (commit 0a3098a27, "add contextual task panels") reworked
getTasks in packages/webui/src/daemon/session/actions.ts at the same spot
this PR modifies. The conflict is semantic, not merely adjacent — both
branches edited the same function body.

What each side did

  • This PR: added an opts param so polling can pass { silent: true };
    transient errors (abort / HTTP 5xx,408,429 / timeout / network) rethrow with no
    notice, hard failures dispatch a de-duplicated notice once.
  • main (feat(web-shell): add contextual task panels #7929): hoisted the sessionRef.current check above the try and
    made "Daemon session is not connected" reject without any notice.

Resolution (semantic merge)

Kept the PR's opts/silent machinery and adopted main's no-session behavior.
The not-connected guard runs first, so it rejects silently regardless of
silent; only then does transient/hard-failure logic apply:

async getTasks(opts) {
  const session = sessionRef.current;
  if (!session) throw new Error('Daemon session is not connected');
  try {
    return await withActionTimeout(session.tasks(), 'Get tasks timed out');
  } catch (error) {
    if (error instanceof Error &&
        error.message === 'Daemon session is not connected') throw error;
    if (opts?.silent && isTransientActionError(error)) throw error;
    throw dispatchActionError(/* …load_tasks, silent once-only notice… */);
  }
},

In the test file the sides added different tests, so both were kept: the PR's
polling-classification suite plus main's rejects getTasks silently when no session exists.

What is load-bearing

  • Guard order: not-connected must precede the transient check. That message
    is not transient per isTransientActionError, so in silent mode it would
    otherwise fall through to a once-only hard-failure notice — contradicting
    main's intent. Reordering these, or classifying "not connected" as transient,
    changes the no-session notice behavior.
  • The hoisted pre-check and a catch-side message check are both retained:
    the former covers "no session at call time", the latter a disconnect during
    session.tasks().

What I could not verify

No build/typecheck/tests run (out of scope). Behavior is covered by tests on both
sides, but no test asserts the silent + not-connected combination; the resolution
makes it reject silently (main's semantics) — untested but consistent with both.

中文说明

根因

main 的 PR #7929(commit 0a3098a27)重写了 actions.tsgetTasks,与本 PR
改的是同一函数体,属语义冲突而非仅相邻。

双方改动

  • 本 PR:新增 opts,轮询可传 { silent: true };瞬时错误(abort / HTTP
    5xx、408、429 / 超时 / 网络)直接抛出不弹通知,硬失败去重后只弹一次。
  • main(feat(web-shell): add contextual task panels #7929:把 sessionRef.current 检查上提到 try 前,让 “not connected”
    失败完全不弹通知

解决方式

保留本 PR 的 opts/silent 机制,并采纳 main 的无会话静默拒绝。not-connected 判断
放在最前,无论是否 silent 都静默拒绝,其后才走瞬时/硬失败逻辑(见上方代码)。
测试文件双方新增的是不同测试,故全部保留。

关键点

  • 判断顺序:not-connected 必须先于瞬时判断。该消息不被 isTransientActionError
    视为瞬时,silent 模式下若排到后面会落入“只弹一次”的硬失败通知,违背 main 意图。
  • 上提预检查与 catch 内消息判断都保留:前者处理“调用时无会话”,后者处理
    session.tasks() 期间断开。

无法验证

本命令不运行 build/typecheck/测试。行为双方均有测试覆盖,但“silent + 无会话”
组合无专门测试;解决结果让其静默拒绝(main 语义),与双方意图一致。

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

Reviewed. Suggestions are inline.

— qwen3.8-max-preview via Qwen Code /review

@@ -1435,20 +1452,53 @@ function dispatchActionError(
return abortError;
}
const message = error instanceof Error ? error.message : String(error);

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.

[Suggestion] The new getActionErrorMessage(error) helper duplicates this exact inline expression. The helper is used by getActionErrorNoticeKey and isTransientActionError, but this pre-existing copy in dispatchActionError was left in place. — Concrete cost: the error→message normalization rule now lives in two places in the same file; if a future change handles a new error shape in one copy it must mirror the other, or the dedupe key (getActionErrorNoticeKey) and the displayed notice message diverge, so a hard failure could be deduped under a different string than the one shown.

Suggested change
const message = error instanceof Error ? error.message : String(error);
const message = getActionErrorMessage(error);

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +1493 to +1499
const message = getActionErrorMessage(error).toLowerCase();
return (
message.includes('timed out') ||
message.includes('failed to fetch') ||
message.includes('network error') ||
message.includes('networkerror')
);

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.

[Suggestion] For a non-HTTP error (plain Error, no numeric status), classification falls through to this substring check, so a permanent hard failure whose message merely contains one of these tokens is classified transient. — Failure scenario: a daemon/SDK bug throwing e.g. Error("failed to fetch model config: invalid response") or Error("NetworkError: bad certificate") on every 3s poll is classified transient forever → getTasks({ silent: true }) rethrows with no notice and no dedupe tracking, and the useBackgroundTasks poller only console.warns and keeps polling, so a permanently broken daemon produces zero user-facing notices — defeating the PR's "surface hard failures once" goal. HTTP-status errors are immune (the status branch returns first). This is distinct from the open thread at line 1147 (which objects to the lack of escalation); here the classification itself mislabels a hard failure. — Suggested fix: prefer a typed signal over message sniffing for the non-HTTP case, or route message-matched errors through the same dedupe set so a never-resolving "transient" failure surfaces once.

— qwen3.8-max-preview via Qwen Code /review

@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 issues found. LGTM! ✅

中文说明

未发现问题。LGTM!✅

— qwen3.8-max-preview via Qwen Code /review

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — real environment ✅

I built this branch locally and verified it against a real qwen serve daemon driving the real Web Shell in Chromium, not just the unit tests. Summary: the PR does exactly what it claims, with no regressions found. Two non-blocking follow-up notes at the end.

Verified at merge-base e81c9dd → PR head f3436c1 · macOS 26.6 (arm64) · Node v24.18.1 · npm 11.16.0 · qwen-code v0.21.2


1. Test / static checks (all green)

Check Result
@qwen-code/webui full suite 407 passed / 28 files
@qwen-code/web-shell full suite 2632 passed / 161 files
webui focused: actions.test.ts + httpErrors.test.ts ✅ 45 passed
web-shell focused: useBackgroundTasks.test.tsx ✅ 4 passed
typecheck (webui + web-shell) ✅ clean
eslint (5 changed source files) ✅ clean
prettier --check (8 changed files) ✅ clean

2. Real-environment behavioural verification

Rather than trusting the unit tests alone, I stood up the actual stack and measured the toasts the UI really raises:

Chromium ──► vite dev server (real web-shell + webui source)
                   │
                   ▼
          fault-injecting reverse proxy   ← forces GET /session/:id/tasks to 503 / 403
                   │
                   ▼
          real `qwen serve` daemon (real session b6a48905…, real SSE stream)

Only two things are injected: the HTTP status of GET /session/:id/tasks, and one monitor tool_call frame on the live SSE stream (that normally needs a model-driven turn, and is what flips taskActivityKey so useBackgroundTasks starts its 3s loop). Everything else — session creation, /load, SSE, transcript, rendering, the toast host — is the real product.

Toasts were counted with a MutationObserver on [data-web-shell-toast] insertions, because pushToast de-dupes identical messages — so spam shows up as repeated insertions, not a growing stack.

I ran the identical scenario twice: once on this branch, and once with the four changed source files reverted to the merge-base (main).

Scenario polls toasts on main toasts on this PR
Transient503 for ~16s 5 🔴 5 (one per poll) 🟢 0
Hard403 for ~16s 6 🔴 6 (one per poll) 🟢 1 (first only)

Identical numbers were reproduced on the project's own Playwright mock-daemon harness, so the result is not an artifact of the proxy.

Note the toast auto-dismiss is 5000ms but the poll interval is 3000ms — so on main a persistently failing poll produces a toast that never goes away, because each poll re-arms it. That is the actual user-visible symptom this PR fixes.

Evidence

Real Web Shell connected to the real daemon (real session id, workspace and model visible bottom-left/bottom-right):

Transient failure (503), 16.5s / 5 polls in:

main — toast permanently stuck This PR — clean

Hard failure (403) — the error is still reported, exactly once:

This PR @ 2.5s — one toast This PR @ 16.5s — gone, never returns main @ 16.5s — still stuck

This is the important part: the hard failure is not swallowed. It surfaces once and then stays quiet, which is exactly the stated design.


3. Review notes (both non-blocking)

a) A second 3s getTasks poller is still un-silenced — and it takes over exactly when the quiet one stands down.

packages/web-shell/client/components/messages/TasksStatusMessage.tsx:288-312 runs its own setInterval(refresh, 3000) calling actions.getTasks() with no options, so every failure still goes through dispatchActionError → one toast per poll. Meanwhile useBackgroundTasks deliberately pauses while that panel is open (if (tasksPanelActive) return;, useBackgroundTasks.ts:51, driven by the panel's own dispatchActive(id, isOpen) at TasksStatusMessage.tsx:353).

So with the tasks panel open, the user gets the pre-PR behaviour back at the same 3s cadence. That component already tracks refreshError and renders an inline indicator, so the toast is redundant there anyway. Pre-existing, but it sits squarely inside this PR's stated scope — worth a follow-up (or a one-line { silent: true } here).

b) silentHardFailureNoticeKeys is never cleared on success.

It is only reset in clearActiveSessionState() (actions.ts:151). Two consequences:

  • A hard failure that resolves and later recurs with the same message is silently swallowed for the rest of the session — the user gets no signal the second time.
  • The Set is unbounded. The key is `${operation}:${message}` and message comes from the daemon body (failOnErrorbody.error). If any error detail ever varies per response (a request id, a timestamp), a 3s poll adds a fresh key every tick — which both defeats the dedupe and grows without limit in a long-lived session.

Suggestion: clear the key on the next successful getTasks, and cap the Set size.

Neither of these blocks merge — the PR is a clear net improvement over the current behaviour, and it is verifiably correct for the path it targets.

Verdict: LGTM, safe to merge. 👍

🇨🇳 中文版本

本地真实环境验证报告 ✅

我在本地构建了这个分支,并在真实的 qwen serve daemon 上、用 Chromium 跑真实的 Web Shell 做了验证,而不只是跑单元测试。结论:这个 PR 的行为与描述完全一致,未发现回归。 末尾有两条不阻塞合并的后续建议。

验证基线 e81c9dd → PR head f3436c1 · macOS 26.6 (arm64) · Node v24.18.1 · npm 11.16.0 · qwen-code v0.21.2


1. 测试与静态检查(全绿)

检查项 结果
@qwen-code/webui 全量测试 407 通过 / 28 个文件
@qwen-code/web-shell 全量测试 2632 通过 / 161 个文件
webui 聚焦:actions.test.ts + httpErrors.test.ts ✅ 45 通过
web-shell 聚焦:useBackgroundTasks.test.tsx ✅ 4 通过
typecheck(webui + web-shell) ✅ 通过
eslint(5 个改动源文件) ✅ 通过
prettier --check(8 个改动文件) ✅ 通过

2. 真实环境行为验证

我没有只依赖单测,而是把整条链路真实跑起来,直接测量 UI 实际弹出的 toast 数量:

Chromium ──► vite dev server(真实 web-shell + webui 源码)
                   │
                   ▼
          故障注入反向代理   ← 强制 GET /session/:id/tasks 返回 503 / 403
                   │
                   ▼
          真实 `qwen serve` daemon(真实 session b6a48905…,真实 SSE 流)

只注入了两样东西:GET /session/:id/tasks 的 HTTP 状态码,以及在真实 SSE 流上补了一个 monitortool_call 帧(这个正常需要模型驱动的一轮对话才会产生,它负责翻转 taskActivityKey,从而让 useBackgroundTasks 启动 3 秒轮询)。其余全部是真实产品代码:建会话、/load、SSE、transcript、渲染、toast host。

toast 用 MutationObserver 监听 [data-web-shell-toast] 的插入来计数——因为 pushToast 会对相同文案去重,所以"刷屏"表现为反复插入,而不是堆栈变长。

同一套场景我跑了两遍:一遍在本分支上,一遍把 4 个改动源文件回退到 merge-base(main)。

场景 轮询次数 main 上的 toast 本 PR 的 toast
瞬时失败503 持续约 16 秒 5 🔴 5(每次轮询一个) 🟢 0
硬失败403 持续约 16 秒 6 🔴 6(每次轮询一个) 🟢 1(仅首次)

同样的数字在项目自带的 Playwright mock daemon harness 上也复现了,所以结果不是代理带来的假象。

注意 toast 自动消失是 5000ms,而轮询间隔是 3000ms —— 所以在 main 上持续失败的轮询会让 toast 永远不消失,因为每次轮询都会把它重新顶上来。这正是这个 PR 修掉的用户可见症状。

证据截图

真实 Web Shell 连上真实 daemon(左下 workspace、右下模型、侧栏真实 session id 都可见):

瞬时失败(503),第 16.5 秒 / 第 5 次轮询:

main —— toast 一直卡住不走 本 PR —— 干净

硬失败(403)—— 错误仍然会上报,且只报一次:

本 PR @ 2.5s —— 一个 toast 本 PR @ 16.5s —— 消失且不再出现 main @ 16.5s —— 仍然卡着

这一点很关键:硬失败没有被吞掉,它会出现一次然后保持安静,与设计描述完全一致。


3. Review 意见(两条都不阻塞合并)

a) 还有第二个 3 秒 getTasks 轮询没被 silent 化,而且它恰好在安静的那个停下时接管。

packages/web-shell/client/components/messages/TasksStatusMessage.tsx:288-312 自己跑了一个 setInterval(refresh, 3000),调用 actions.getTasks() 且不带 options,所以每次失败仍然走 dispatchActionError → 每轮一个 toast。而 useBackgroundTasks 在该面板打开时是刻意暂停的(if (tasksPanelActive) return;useBackgroundTasks.ts:51,由面板自身的 dispatchActive(id, isOpen) 驱动,见 TasksStatusMessage.tsx:353)。

也就是说,只要任务面板打开,用户就会以同样的 3 秒频率拿回 PR 之前的行为。而且该组件本身已经维护了 refreshError 并渲染了行内错误提示,toast 在那里本来就是冗余的。这个问题是既有的,但正落在本 PR 声明的范围内——建议后续跟进(或者在这里补一行 { silent: true })。

b) silentHardFailureNoticeKeys 在成功后从不清理。

它只在 clearActiveSessionState() 里被重置(actions.ts:151)。两个后果:

  • 某个硬失败恢复后、之后又以相同文案复发,在这个 session 的剩余时间里会被完全静默——用户第二次拿不到任何信号。
  • 这个 Set 是无界的。key 是 `${operation}:${message}`,而 message 来自 daemon 的响应体(failOnErrorbody.error)。只要错误详情里有任何逐次变化的内容(request id、时间戳),3 秒轮询就会每次新增一个 key ——既让去重失效,又会在长会话里无限增长。

建议:下一次 getTasks 成功时清掉对应 key,并给 Set 加个容量上限。

这两点都不阻塞合并 —— 相比当前行为这个 PR 是明确的净改进,并且在它针对的路径上是可验证正确的。

结论:LGTM,可以合并。 👍

@wenshao
wenshao added this pull request to the merge queue Aug 1, 2026
Merged via the queue into QwenLM:main with commit a822ee4 Aug 1, 2026
41 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.3.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants