Skip to content

fix(web-shell): bound daemon transcript retention to stop renderer OOM crashes - #9303

Merged
wenshao merged 28 commits into
QwenLM:mainfrom
wenshao:fix/web-shell-transcript-memory-growth
Aug 21, 2026
Merged

fix(web-shell): bound daemon transcript retention to stop renderer OOM crashes#9303
wenshao merged 28 commits into
QwenLM:mainfrom
wenshao:fix/web-shell-transcript-memory-growth

Conversation

@wenshao

@wenshao wenshao commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Bounds how much daemon session history the web shell retains in the browser. The raw replay snapshot fetched when a session loads is released as soon as it has been injected into the transcript store. Replay rebuilds now run under the same block cap as live growth, and a replay can no longer ratchet that cap above its configured limit — oversized replays are trimmed to the most recent blocks instead. Diagnostic blocks for unrecognized session update kinds embed a capped excerpt rather than the full payload. The subagent detail pane receives the same block cap as the rest of the web shell, and the implicit provider default retention window is lowered.

Why it's needed

Watching a busy daemon session — for example a long single turn fanning out to many subagents — could exhaust the browser renderer's memory and crash the tab. Confirmed from a real crash: a session that produced well over 100,000 session-update events during a 51-minute turn drove its Chrome renderer past 5GB RSS before it aborted (breakpoint-style crash signature consistent with heap exhaustion), and every reload of the same session re-ingested the uncapped replay and grew back to multiple GB within minutes. Crashpad dumps show the same renderer crash signature recurring over several days. The daemon's per-session replay window grows adaptively for in-flight turns (up to hundreds of MB), so the client must not retain or rebuild it without a bound.

Reviewer Test Plan

How to verify

  1. Run the unit suites (all green locally): sdk-typescript (1552 tests), webui (505), web-shell (3655), plus repo-wide build and typecheck.
  2. Review the new regression tests: the webui provider suite now asserts that an oversized replay is trimmed to the configured block cap, that the session client's snapshot is consumed exactly once at injection, and that later live growth still trims at the cap (i.e. the cap was not raised). The SDK suite asserts the snapshot-release semantics and the capped diagnostic text for unrecognized session update kinds.
  3. Behaviorally: open the web shell on a session whose replay exceeds the block cap — the transcript should show the most recent blocks, older history stays reachable via pagination until the store is full, and the capacity notice appears instead of the load-older control once saturated.

Evidence (Before & After)

Before (observed live): the renderer hosting the web shell grew 2.0GB → 4.2GB → 5.1GB while streaming busy sessions, crashed with a breakpoint-style abort at ~10:11 today (Crashpad dump timestamp matches the SSE disconnect to the second), and the replacement renderer regrew to 2GB within ~5 minutes of reload; Crashpad holds identical-signature renderer dumps from Aug 10/14/15/16. Root causes confirmed by code path: the replay snapshot pinned on the session client for the attachment lifetime, an uncapped replay rebuild, and a replay-size escalation of the committed block cap.
After: the retention paths are bounded by construction and covered by the regression tests listed above (cap never raised, snapshot released at injection, per-frame diagnostic text capped, all providers on the 50k cap). A long-soak browser before/after comparison was not rerun.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Local daemon + web shell via npm run dev on macOS (the crash investigation ran against the live daemon on 127.0.0.1:4170); unit suites via vitest.

Risk & Scope

  • Main risk or tradeoff: loading a session whose replay exceeds the block cap now renders only the most recent retained blocks instead of holding the entire replayed window in memory; older history remains reachable through pagination until capacity, matching the existing capacity-reached UX. The implicit provider default window drops from 200k to 50k blocks (all web-shell surfaces already pass 50k explicitly).
  • Not validated / out of scope: the transient parse peak while a large replay response is downloaded is unchanged (governed by daemon-side journal caps); daemon-side adaptive journal growth is untouched.
  • Breaking changes / migration notes: none at the API level; the session client's replay snapshot becomes a getter with an added consume method (additive).

Linked Issues

None — found while investigating a live renderer crash; no existing issue to close.

中文说明

本 PR 做了什么

限制 Web Shell 在浏览器中保留的守护进程会话历史量。会话加载时拉取的原始 replay 快照在被注入 transcript store 后立即释放。replay 重建现在与实时增长使用同一个 block 上限,且 replay 不再能把该上限抬高到配置值以上——超限的 replay 会被修剪为最近的若干 block。针对未识别 session update 类型的诊断 block 只嵌入截断后的摘要,而不是完整载荷。subagent 详情面板现在与 Web Shell 其余部分使用相同的 block 上限,provider 的隐式默认保留窗口也相应调低。

为什么需要

观看一个繁忙的守护进程会话(例如单个超长 turn 扇出大量 subagent)可能耗尽浏览器 renderer 的内存并导致标签页崩溃。来自真实崩溃的确认:一个在 51 分钟的 turn 中产生了远超 10 万条 session-update 事件的会话,使其 Chrome renderer 在 abort 前超过 5GB RSS(断点式崩溃签名,与堆耗尽一致),并且每次重新加载同一会话都会重新吸入无上限的 replay,在几分钟内重新涨到数 GB。Crashpad 转储显示同一 renderer 崩溃签名已连续多天反复出现。守护进程的每会话 replay 窗口会对进行中的 turn 自适应增长(可达数百 MB),因此客户端必须对其保留与重建设置上限。

评审测试计划

如何验证

  1. 运行单测套件(本地全绿):sdk-typescript(1552 个测试)、webui(505)、web-shell(3655),以及仓库级 build 与 typecheck。
  2. 审阅新增回归测试:webui provider 套件现在断言超限 replay 会被修剪到配置的 block 上限、会话客户端的快照在注入时恰好被消费一次、且后续实时增长仍按该上限修剪(即上限未被抬高)。SDK 套件断言快照释放语义,以及未识别 session update 类型的诊断文本被截断。
  3. 行为验证:在 Web Shell 打开一个 replay 超过 block 上限的会话——transcript 应显示最近的 block,更早的历史可通过分页加载直到 store 满,饱和后显示容量提示而不是"加载更早历史"控件。

证据(修复前后)

修复前(实际观测):承载 Web Shell 的 renderer 在流式接收繁忙会话时从 2.0GB → 4.2GB → 5.1GB 增长,今天 10:11 以断点式 abort 崩溃(Crashpad 转储时间戳与 SSE 断开精确到秒一致),重载后的新 renderer 约 5 分钟内重新涨到 2GB;Crashpad 中存有 8 月 10/14/15/16 日同签名的 renderer 转储。根因已通过代码路径确认:replay 快照在整个 attach 生命周期被固定在会话客户端上、replay 重建无上限、以及按 replay 规模抬高 committed block 上限。
修复后:上述保留路径在构造上即有界,并由前述回归测试覆盖(上限不被抬高、注入时释放快照、每帧诊断文本截断、所有 provider 统一 50k 上限)。未重新进行长时间浏览器前后对照浸泡测试。

测试环境

操作系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS 上通过 npm run dev 运行本地 daemon + Web Shell(崩溃排查针对 127.0.0.1:4170 上的真实 daemon 进行);单测通过 vitest 运行。

风险与范围

  • 主要风险或权衡:加载 replay 超过 block 上限的会话时,现在只渲染最近保留的 block,而不是把整个 replay 窗口留在内存中;更早的历史仍可通过分页加载直到容量用尽,与既有的"容量已达"UX 一致。provider 隐式默认窗口从 20 万降至 5 万 block(Web Shell 各界面本就已显式传 50k)。
  • 未验证 / 超出范围:下载大型 replay 响应时的瞬时解析峰值未改变(由 daemon 侧 journal 上限约束);daemon 侧自适应 journal 增长未改动。
  • 破坏性变更 / 迁移说明:API 层面无破坏性变更;会话客户端的 replay snapshot 改为 getter 并新增 consume 方法(纯增量)。

关联 Issue

无——排查线上 renderer 崩溃时发现,没有可关闭的既有 issue。

…M crashes

Watching a busy daemon session (long turn, many subagents) could exhaust
the browser renderer: the replay snapshot stayed pinned on the session
client for the whole attachment, replay rebuilds ran uncapped and could
ratchet the transcript block cap above its configured limit, and a few
retention windows were implicitly far larger than intended. Observed as a
Chrome renderer abort after multi-GB growth, reproducible on every reload
of the affected session.

- Release the replay snapshot once it is injected into the transcript
  store (SSE resumes from lastEventId; older history via pagination)
- Rebuild replays under the configured maxBlocks cap and never raise the
  committed cap above it; trimming keeps the most recent blocks
- Cap the debug text embedded for unrecognized session_update kinds,
  which appended one block per frame with the full payload
- Pass the web-shell block cap to the subagent detail provider and lower
  the provider default window from 200k to 50k blocks
@wenshao

wenshao commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Aug 17, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 17, 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 1fc9980. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

⚠️ No preview: one or more scenarios failed to render on this head — see the workflow run. This is not "no visual change" — a scenario that times out or throws produces no image. Fix the failing scenario (or a genuine regression it caught) and the preview returns on the next push.

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

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! Re-running triage on the author's re-trigger. Since the last pass (8447f29) three autofix commits landed: ca93bb01d (close the round-13 criticals), 0cc8d8177 (close the round-14 criticals), and 1fc998038 (R15-1 — integral-ize a fractional maxBlocks). This pass re-gates at the new head 1fc9980 and verifies each of those fixes in Stage 2.

Template looks good ✓

Problem: observed, not theoretical. First-hand crash evidence — a renderer growing 2.0GB → 4.2GB → 5.1GB on a busy session, a breakpoint-style abort consistent with heap exhaustion, Crashpad dumps with the same signature recurring over several days, and every reload re-ingesting the uncapped replay — with three retention paths identified in code (snapshot pinned for the attachment lifetime, uncapped replay rebuild, replay ratcheting the committed block cap).

Direction: aligned. The web shell is a first-class surface and client-side retention must be bounded — the daemon's replay window grows adaptively during in-flight turns, so the client can't retain or rebuild it wholesale. No CHANGELOG signal needed for a stability fix in this repo's own web shell.

Size: cross-package (sdk-typescript, webui, web-shell): ~966 production lines vs ~2,069 test lines (0 generated/schema) at this head — the three new commits are overwhelmingly regression tests. The author holds admin access, so the core-change gate is exempt, and production stays under the 1,000-line large-PR advisory regardless.

Approach: unchanged from the prior pass — the retention mechanism is the minimal set needed for the stated goal, with no unrelated changes. The three new commits are tightly scoped to the exact defects rounds 13–15 filed; no drive-by edits.

Risk: no high-risk path matches from the revert-history analysis (no shell/mcp/lsp/sandbox/acp surfaces touched). No elevated risk signals.

Moving on to code review. 🔍

中文说明

感谢贡献!应作者的重新触发再次 triage。自上次审查(8447f29)以来新增了三个 autofix 提交:ca93bb01d(关闭第 13 轮 critical)、0cc8d8177(关闭第 14 轮 critical)、1fc998038(R15-1——把小数 maxBlocks 取整)。本次在新 head 1fc9980 上重新过门槛,并在 Stage 2 逐一核实这些修复。

模板完整 ✓

问题:已观测到的真实问题,非理论性加固。第一手崩溃证据——繁忙会话中 renderer 从 2.0GB → 4.2GB → 5.1GB 增长、与堆耗尽一致的断点式 abort、连续多天反复出现同签名的 Crashpad 转储、每次重载都重新吸入无上限 replay——并已在代码中定位三条保留路径。

方向:对齐。Web Shell 是一等界面,客户端保留量必须有界。本仓库自身 Web Shell 的稳定性修复无需 CHANGELOG 信号。

规模:跨三个包:当前 head 约 966 行生产代码、约 2,069 行测试代码(0 行生成/schema)——三个新提交绝大多数是回归测试。作者持有 admin 权限,核心改动门槛豁免;生产行数也低于 1,000 行大 PR 建议线。

方案:与上次审查结论一致——保留机制是达成目标的最小集合,无顺手改动。三个新提交都精准对应第 13–15 轮提出的缺陷,无顺带修改。

风险:回滚历史分析未命中任何高风险路径。无升级风险信号。

进入代码审查。🔍

Qwen Code · qwen3.8-max

Reviewed at 1fc9980382f090247586c1bac6d9e3c26a4ece3d · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Code review

This re-run covers the delta 8447f291fc9980: three autofix commits that close the round-13, round-14, and round-15 findings. The retention mechanism reviewed in prior passes stands; this pass verifies the fixes landed and looks for new blockers.

The three round-13 Criticals that gated the PR are all fixed and present at this head. I re-read each implicated region at 1fc9980:

  • R13-1 — floor back-off is now a while (transcript.ts). The one-shot if is a loop that re-retains siblings while the boundary pair shares a record, and the floor-only removeCount === len - 1 guard was dropped (the forward snap already cleans non-floor cuts, so the loop only fires at the floor). The removeCount === 0 escape keeps the whole window rather than cutting mid-record when nothing is evictable. A new 3-block-record regression test (R12-21) pins it. ✅
  • R13-2 — live trim now restores hasMore for unlatched sessions (DaemonSessionProvider.tsx). The re-anchor branch offers the load-older affordance under the same gates as the replay path (paginationSupported && postTrimRetainedBytes < byteCap), but only when !capacityReached && !hasMore — so it doesn't collide with the rejectedPage latch path. ✅
  • R13-3 — boundary-echo dedup keys on echo presence + media (DaemonSessionProvider.tsx). userBlockBoundaryKey returns a key for any recordId-less user block (empty text included) and folds image/file counts into the key, so media-only prompts dedup and two distinct media-only prompts aren't collapsed. ✅

Round-14 and round-15 fixes are also present and coherent. effectiveMaxBlocks clamps non-positive/non-finite to ≥1 (R14-1) and Math.floors fractional values (R15-1), so removeCount can't go zero or fractional and read past the block array — both pinned by new tests. The re-anchor branch grows a latched rejectedPage footprint by the evicted band (so the re-open gate measures the page the re-anchored fetch actually gets), the rebuild path drops an uncomputable anchor unconditionally, and loadMore refuses to re-arm anchor-less. I traced R13-2's hasMore gate against the rejectedPage latch path and the round-14 test's hasMore:false, capacityReached:true expectation — they compose correctly (the latch path sets capacityReached, which R13-2's !capacityReached gate skips).

No new blockers found in this pass. The fixes are minimal, each pinned by a regression test, and I found no correctness regression introduced by them.

Two things I did not re-verify here, disclosed: the block-count/byte caps and the snapshot-release mechanism were validated in prior passes and are untouched by this delta; and the persistent advisory verify findings below are unchanged by these commits.

Sandboxed verification (behavioural claim)

The central claim — retention stays bounded in the renderer — is substantiated by the last completed /verify run (head 8447f29): A/B 39/39 (every head cell holds, every base control fails as predicted), all three workspace suites green, and the mutation matrix kills the five load-bearing guards. A fresh /verify run for the current head is in flight; its report posts to its own thread and should be read before merge.

That run also carries three advisory, Suggestion-severity findings — explicitly "not a review, an approval, or a CI check" — that this delta does not touch and that a maintainer already weighed before approving:

  • F1retainedBytes accounting under-counts four block-mutation paths (applyAssistantUsage, resolvePermissionBlock, upsertPermissionBlock existing-branch, applySubagentUsageToParentTool). Verify's own words: direction is under-count, so the ceiling is budget + worst-case block + accumulated unaccounted deltasbounded, no correctness hazard, but it breaks the PR's exactness invariant. The block-count cap is the hard backstop.
  • F2toolPreview named rows (Path/Cwd/Query/Note) bypass capDetails; bounded (same string ref rawInput already carries), pre-existing gap.
  • F3 — R12-1 / R12-2 / R12-10 ship unpinned by a test (3 surviving mutants).

These are genuine follow-ups, not merge-blockers; I'd suggest a tracking issue so they aren't lost once this lands.

Test evidence

Unattended CI run — the PR's own checks are the evidence; no PR code was executed here. All substantive checks are green on 1fc9980 with zero failure conclusions, and there are no pending pull_request-event workflow runs. The only in-progress check is review-pr (bot orchestration on the review event, not PR CI); the skipped rows are platform variants not run on this event.

CI results for 1fc9980:

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
Secret scan (TruffleHog) ✅ success
Dependency CVE audit ✅ success
Classify PR ✅ success
Qwen Code CI / Security Checks / Web-shell Visuals ✅ success
Test (macos/windows), Integration Tests (CLI) ⚪ skipped on this event

Green CI settles that the suite passes; the memory claim is settled by the A/B verify above, not by CI. Not verified here: long-soak renderer memory on the current head (the fresh /verify run covers the bounded-retention A/B; the author's soak was macOS-only and not rerun). Unit/build/typecheck green is CI evidence, not re-executed here — unattended runs never run PR code.

中文说明

代码审查

本次 re-run 覆盖 8447f291fc9980 的增量:三个 autofix 提交,分别关闭第 13、14、15 轮的发现。此前各轮审查过的保留机制保持不变;本次核实修复已落地,并查找新阻断项。

门槛上那三个第 13 轮 Critical 均已修复且存在于本 head。 我在 1fc9980 上重新阅读了每一处涉及区域:

  • R13-1 —— floor 回退已是 whiletranscript.ts)。一次性 if 改为循环:只要边界对共享 record 就持续回退重新保留兄弟块;且去掉了"仅在 floor"的限制(前向 snap 已清理非 floor 切口,故循环只会在 floor 触发)。removeCount === 0 出口保证无可驱逐时保留整个窗口。新增 3-block record 回归测试(R12-21)钉住。✅
  • R13-2 —— 实时裁剪现在为未锁存会话恢复 hasMoreDaemonSessionProvider.tsx)。重锚定分支按与 replay 路径相同的门控提供"加载更早"入口,且仅在 !capacityReached && !hasMore 时生效——不与 rejectedPage 锁存路径冲突。✅
  • R13-3 —— 边界回显去重以"回显存在 + 媒体"为键DaemonSessionProvider.tsx)。userBlockBoundaryKey 对任何无 recordId 的 user block(含空文本)返回键,并把图片/文件数计入键——纯媒体提问能去重,边界处两条不同的纯媒体提问不会被错误合并。✅

第 14、15 轮修复也已落地且自洽。 effectiveMaxBlocks 把非正/非有限值钳到 ≥1(R14-1),并对小数值 Math.floor(R15-1),removeCount 不会变 0 或小数而越界——均有新测试钉住。重锚定分支按被驱逐段增大锁存的 rejectedPage 足迹;rebuild 路径在锚点不可计算时无条件丢弃;loadMore 拒绝在无锚点时重新武装。我把 R13-2 的 hasMore 门控与 rejectedPage 锁存路径、以及第 14 轮测试的 hasMore:false, capacityReached:true 期望对照——组合正确(锁存路径置 capacityReached,R13-2 的 !capacityReached 门控恰好跳过)。

本次未发现新阻断项。 修复均为最小改动、各有回归测试钉住,未发现其引入正确性回退。

两处未在此重复核实,披露:block 数/字节上限与快照释放机制已在前几轮验证、且本增量未触及;下述持续性的 advisory verify 发现也未被这些提交改变。

沙箱验证(行为性主张)

核心主张——renderer 中保留量保持有界——已由上一次完成的 /verify 运行证实(head 8447f29):A/B 39/39(head 侧全部成立、base 对照按预期失败)、三个 workspace 套件全绿、变异矩阵杀死五个关键守卫。针对当前 head 的新一轮 /verify 正在进行;报告会发布在对应线程,合并前应阅读。

该运行还报告三个 advisory、Suggestion 级 发现——明确"不构成评审、批准或 CI 检查"——本增量未触及,且 maintainer 在批准前已知悉:

  • F1 —— retainedBytes 记账在四条 block 变更路径上少计。verify 原话:方向为少计,上限为"预算 + 最坏单块 + 累计未计增量"——有界、无正确性危害,但打破了 PR 的精确性不变量。block 数上限是硬兜底。
  • F2 —— toolPreview 命名行未走 capDetails;有界(与 rawInput 共享同一字符串引用),既有缺口。
  • F3 —— R12-1 / R12-2 / R12-10 无测试钉住(3 个存活变异体)。

这些是真实的后续项,非合并阻断;建议建一个跟踪 issue,避免合入后遗失。

测试证据

无人值守 CI 运行——以 PR 自身检查为证据,未执行任何 PR 代码。1fc9980 上所有实质检查均绿、无任何 failure,且无待定的 pull_request 事件工作流运行。唯一 in-progress 是 review-pr(评审事件的 bot 编排,非 PR CI);skipped 行为本事件不跑的平台变体。

绿色 CI 只证明套件通过;内存主张由上方 A/B verify 证实,而非 CI。此处未验证:当前 head 的长时间浸泡 renderer 内存(新 /verify 覆盖有界保留 A/B;作者的浸泡仅 macOS 且未重跑)。单测/构建/typecheck 绿为 CI 证据,非此处重跑——无人值守运行从不执行 PR 代码。

Qwen Code · qwen3.8-max

Reviewed at 1fc9980382f090247586c1bac6d9e3c26a4ece3d · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — every blocking Critical from rounds 13–15 is fixed and verified at this head, CI is fully green, and the central memory-bounding claim is A/B-proven; the remainder is advisory (byte-accounting exactness, a few unpinned guards), which is follow-up work, not a merge gate.

Stepping back: the last pass held this at request-changes for three round-13 Criticals, and I wanted to see each one actually land rather than take the autofix summaries on faith. All three did — the floor back-off is a real while loop with a 3-block regression test, the unlatched live-trim path restores hasMore under the right gates, and the boundary dedup keys on echo presence with media folded in. Rounds 14 and 15 then found and closed their own follow-on defects (degenerate/fractional maxBlocks, rejectedPage footprint growth, anchor-less re-arm). That's a lot of rounds — this PR has been through fifteen — and I won't pretend the churn is a strength: each fix kept exposing a new corner of the same retention/pagination logic. But the per-round Critical count converged (3 → 4 → 1), round-15's only finding was a one-keyword derivative of round-14's fix, and the round-15 ledger carries no open Critical. The remaining items are the verify report's three Suggestions (byte-accounting drift, toolPreview rows, unpinned R12 fixes) plus four deferred round-15 probes — all explicitly non-blocking, and a maintainer approved this exact head with those in view.

If I had to maintain this in six months the retention core reads clearly and the regression tests pin the sharp edges; I'd want the F1 accounting invariant closed and those three follow-ups in a tracking issue, but I wouldn't curse the author. The original reproduction (a real renderer OOM with Crashpad evidence) is exactly the kind of observed bug this should fix, and the A/B verify shows the bound holds.

Verdict: approve — approving pinned to 1fc9980. A fresh /verify for this head is still in flight; it's advisory and the core claim is already substantiated, but its report should still be read before merge.

中文说明

置信度:4/5 —— 第 13–15 轮的每一个阻断级 Critical 都已修复并在本 head 上核实,CI 全绿,核心"内存有界"主张已经 A/B 证实;剩下的是 advisory 项(字节记账精确性、少数未钉住的守卫),属后续工作,不构成合并门槛。

退一步看:上次审查以三个第 13 轮 Critical 把本 PR 留在 request-changes,我想亲眼确认每一处真正落地,而不是照单接受 autofix 的总结。三处都落地了——floor 回退是真正的 while 循环并带 3-block 回归测试;未锁存的实时裁剪路径在正确门控下恢复 hasMore;边界去重以"回显存在 + 媒体"为键。第 14、15 轮又发现并关闭了各自的后续缺陷(退化/小数 maxBlocks、rejectedPage 足迹增长、无锚点重武装)。轮次确实很多——本 PR 已历经十五轮——我不粉饰这种反复:每次修复都暴露出同一保留/分页逻辑的新角落。但每轮 Critical 数在收敛(3 → 4 → 1),第 15 轮唯一发现只是第 14 轮修复的一键衍生,且第 15 轮台账已无未关闭的 Critical。剩余项是 verify 报告的三个 Suggestion(字节记账漂移、toolPreview 行、未钉住的 R12 修复)加四条延后的第 15 轮探针——均明确非阻断,且一位 maintainer 已在知悉这些的情况下批准了本 head。

若六个月后由我维护:保留核心清晰、回归测试钉住了锋利边角;我希望关闭 F1 记账不变量并把三个后续项放进跟踪 issue,但不会怪作者。最初的复现(真实 renderer OOM + Crashpad 证据)正是应当修复的已观测 bug,A/B verify 也表明有界性成立。

结论:批准 —— 固定到 1fc9980 批准。针对本 head 的新一轮 /verify 仍在进行;它是 advisory、核心主张已被证实,但合并前仍应阅读其报告。

Qwen Code · qwen3.8-max

Reviewed at 1fc9980382f090247586c1bac6d9e3c26a4ece3d · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.8-max via Qwen Code /review (v0.21.13)

Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts Outdated
Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx
@qwen-code-dev-bot

qwen-code-dev-bot commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

AutoFix round 11 finishedview run. See this round's report below.

中文说明

AutoFix 第 11 轮已完成 —— 查看运行。本轮报告见下方。

…M#9303)

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

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下:

Address-review summary — PR #9303 (round 1)

Feedback points and dispositions

[rc:3793989466] Stale doc comment on WEB_SHELL_MAX_TRANSCRIPT_BLOCKSResolved

Finding (verified): The comment in packages/web-shell/client/constants/sessions.ts justified the constant with "The SDK default (200_000) is far beyond what the virtualized message list renders" — but this PR changed the provider's DEFAULT_MAX_BLOCKS to 50_000, identical to the constant, falsifying that premise. Verified by reading both the constant's comment and the provider diff.

Change: Rewrote the comment to state the true relationship — the value matches the provider's DEFAULT_MAX_BLOCKS and is passed explicitly so Web Shell retention cannot drift if that default changes — while keeping the still-valid performance rationale (per-dispatch block-array copy + full-list normalization cost on buffered SSE bursts). Comment-only change in packages/web-shell/client/constants/sessions.ts; no behavior impact.

[rc:3793989468] Payload cap missing on sibling debug-block paths — Resolved

Finding (verified): The new capDetails(...) payload cap covered only the default session_update branch. Verified by code inspection that five sibling debug paths still embedded the full stringifyRedactedJson(...) payload: normalizeUnrecognizedEvent (unrecognized top-level event type), the session_update malformed-payload branch, two malformed permission_request branches, and the malformed permission_resolved branch. (fallbackDebug, used by ~30 other malformed paths, already embeds only a short reason string.) A newer daemon streaming unrecognized frames at high frequency through any sibling path would accumulate ~100KB blocks and reproduce the same OOM class this PR fixes.

Probe: Temporarily ran the new test against the pre-round normalizer.ts — it failed with expected 100066 to be less than or equal to 4111, demonstrating the uncapped embedding on current code.

Change: Applied capDetails(...) to all five sibling branches in packages/sdk-typescript/src/daemon/ui/normalizer.ts and added a test (caps the embedded payload of the sibling debug-block paths too) in packages/sdk-typescript/test/unit/daemonUi.test.ts covering every sibling branch with a 100KB payload. With the fix, all 295 tests in the file pass; the new test fails against pre-round code, as the gate requires.

[rc:3793989469] Keep-history-state branch has no test — Resolved

Finding (verified): The new guard (same-session reconnect after consumeReplaySnapshot() keeps the history state instead of clobbering it) had no coverage; reverting it to the pre-PR unconditional reset survived the whole suite. Traced the flow: on a PATH-A stream-end resubscribe the attach block re-runs with an empty consumed snapshot and undefined local capabilities, so firstPersistedRecordId/replayHistoryWasTruncated recompute degraded; without the guard the overline would flip hasMore to false and drop beforeRecordId, making older history unloadable.

Change: Added keeps replay-derived pagination state across a same-session reconnect in packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx, following the suggested shape: pagination feature enabled, replay whose hasMore/anchor derive from the replay window (history_truncated marker with fullTranscriptAvailable + a session_update carrying qwen.session.recordId, while historyHasMore/historyAnchorRecordId stay at their false/undefined defaults), a stream-end same-session resubscribe, then asserts history.hasMore is still true and loadMore() requests the original beforeRecordId (record-retained).

Probe (mutation check): Temporarily reverting the guard to if (!repairingEpisode) makes the new test fail (expected false to be true); with the guard restored it passes — the test pins the behavior on the pre-round branch.

[rv:4948674889] Review body — no blockers

The review body itself carried no findings beyond the inline suggestions above; all handled.

[ic:5311514331] web-shell visual preview — informational, no action

The visual-preview bot noted no screenshot changed despite two render-shaping files being edited. Expected: both edits are retention/memory logic (block caps, snapshot release, reconnect pagination state) with no visual effect in the mock-daemon scenarios. No code action taken; adding a visual scenario solely for this would be scope growth.

Conflict notes

--conflict false; no merge performed, branch stays on its own history.

Files changed

  • packages/sdk-typescript/src/daemon/ui/normalizer.ts — cap the five sibling debug-block payload embeddings
  • packages/sdk-typescript/test/unit/daemonUi.test.ts — sibling-path cap test
  • packages/web-shell/client/constants/sessions.ts — corrected WEB_SHELL_MAX_TRANSCRIPT_BLOCKS doc comment
  • packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx — keep-history reconnect regression test

Verification

  • npm run build — passed
  • npm run typecheck — passed (all packages, incl. web-shell and webui)
  • npm run lint — passed
  • npx vitest run in packages/sdk-typescript — 34 files, 1574 passed (1574)
  • npx vitest run src/daemon/session/DaemonSessionProvider.test.tsx in packages/webui — 217 passed (217)
  • npx prettier --check on the four changed files — clean (two files reformatted before commit)
  • Mutation probe 1: new sibling-cap test vs pre-round normalizer.ts — failed as expected (100066 > 4111), passes with the fix
  • Mutation probe 2: new keep-history test vs guard reverted — failed as expected (expected false to be true), passes with the guard
  • Integration tests — not run: the touched behavior (normalizer debug text, provider reconnect pagination) is exercised by the unit/component suites above, not only through the bundled CLI or integration harness
  • Settings schema — not regenerated: no settings source changed
中文说明

处理审查总结 — PR #9303(第 1 轮)

反馈点及处理结论

[rc:3793989466] WEB_SHELL_MAX_TRANSCRIPT_BLOCKS 的文档注释已失效 — 已解决

发现(已核实): packages/web-shell/client/constants/sessions.ts 中的注释以“SDK 默认值(200_000)远超虚拟消息列表所需”作为该常量的理由——但本 PR 已将 provider 的 DEFAULT_MAX_BLOCKS 改为 50_000,与该常量完全相同,使该前提不再成立。已通过阅读常量注释与 provider diff 核实。

改动: 重写注释,说明真实关系——该值与 provider 的 DEFAULT_MAX_BLOCKS 一致,显式传入是为了在默认值变化时 Web Shell 的保留上限不致漂移——同时保留仍然成立的性能理由(SSE 缓冲突发时每 dispatch 复制整个 block 数组 + 全量消息归一化的开销)。仅修改 packages/web-shell/client/constants/sessions.ts 中的注释,无行为影响。

[rc:3793989468] 同类的调试 block 路径缺少载荷截断 — 已解决

发现(已核实): 新的 capDetails(...) 载荷截断只覆盖了 session_update 的 default 分支。经代码检查核实,另外五条同类调试路径仍嵌入完整的 stringifyRedactedJson(...) 载荷:normalizeUnrecognizedEvent(顶层未识别事件类型)、session_update 的 malformed-payload 分支、两个 malformed permission_request 分支、以及 malformed permission_resolved 分支。(约 30 条其他 malformed 路径使用的 fallbackDebug 本来就只嵌入简短的原因字符串。)更新版 daemon 高频推送未识别帧、走任一同类路径时,~100KB 的 block 会不断累积,复现本 PR 所修复的同类 OOM。

探针: 将新测试临时运行在修复前的 normalizer.ts 上——失败,报 expected 100066 to be less than or equal to 4111,证明当前代码确实未截断。

改动:packages/sdk-typescript/src/daemon/ui/normalizer.ts 中对全部五个同类分支套用 capDetails(...),并在 packages/sdk-typescript/test/unit/daemonUi.test.ts 新增测试(caps the embedded payload of the sibling debug-block paths too),用 100KB 载荷覆盖每一个同类分支。修复后该文件全部 295 个测试通过;新测试在修复前代码上失败,符合验证门要求。

[rc:3793989469] “保留历史状态”分支没有测试 — 已解决

发现(已核实): 新守卫(consumeReplaySnapshot() 消费快照后的同会话重连保留历史状态而非覆盖)没有覆盖;将其还原为 PR 前的无条件重置后,整个测试套件全部存活。已追踪流程:PATH-A 流结束重订阅时,attach 块会以空的已消费快照和 undefined 的局部 capabilities 重新执行,firstPersistedRecordId/replayHistoryWasTruncated 重算后退化;若没有守卫,覆盖会把 hasMore 翻成 false 并丢掉 beforeRecordId,导致更早的历史在刷新页面前不可加载。

改动:packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx 新增 keeps replay-derived pagination state across a same-session reconnect,按建议形态编写:启用分页特性,注入一个 hasMore/锚点均来自 replay 窗口的 replay(带 fullTranscriptAvailablehistory_truncated 标记 + 携带 qwen.session.recordIdsession_update,同时 historyHasMore/historyAnchorRecordId 保持默认的 false/undefined),触发一次流结束的同会话重订阅,然后断言 history.hasMore 仍为 true,且 loadMore() 请求的是原始 beforeRecordIdrecord-retained)。

探针(突变检查): 将守卫临时还原为 if (!repairingEpisode) 后新测试失败(expected false to be true);恢复守卫后通过——该测试在修复前分支上即可钉住此行为。

[rv:4948674889] 审查正文 — 无阻断问题

审查正文本身除上述行内建议外没有其他发现;均已处理。

[ic:5311514331] web-shell 视觉预览 — 信息性,无需处理

视觉预览机器人指出:尽管编辑了两个影响渲染的文件,但没有任何截图变化。符合预期:两处修改都是保留/内存逻辑(block 上限、快照释放、重连分页状态),在 mock daemon 场景下没有视觉效果。不采取代码行动;仅为该提示新增视觉场景属于范围扩张。

冲突说明

--conflict false;未执行合并,分支保持在自身历史上。

变更文件

  • packages/sdk-typescript/src/daemon/ui/normalizer.ts — 对五条同类调试 block 的载荷嵌入套用截断
  • packages/sdk-typescript/test/unit/daemonUi.test.ts — 同类路径截断测试
  • packages/web-shell/client/constants/sessions.ts — 修正 WEB_SHELL_MAX_TRANSCRIPT_BLOCKS 的文档注释
  • packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx — 保留历史状态的重连回归测试

验证

  • npm run build — 通过
  • npm run typecheck — 通过(全部包,含 web-shell 与 webui)
  • npm run lint — 通过
  • packages/sdk-typescriptnpx vitest run — 34 个文件,1574 通过(1574)
  • packages/webuinpx vitest run src/daemon/session/DaemonSessionProvider.test.tsx — 217 通过(217)
  • 对四个变更文件执行 npx prettier --check — 干净(其中两个文件在提交前已重新格式化)
  • 突变探针 1:新的同类截断测试 vs 修复前 normalizer.ts — 按预期失败(100066 > 4111),修复后通过
  • 突变探针 2:新的保留历史测试 vs 守卫还原 — 按预期失败(expected false to be true),恢复守卫后通过
  • 集成测试 — 未运行:所触及的行为(normalizer 调试文本、provider 重连分页)由上述单元/组件测试套件覆盖,并非只能通过打包后的 CLI 或集成测试框架验证
  • settings schema — 未重新生成:未改动任何 settings 源

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@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 via Qwen Code /review (v0.21.13)

Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts Outdated
…ipt-memory-growth

# Conflicts:
#	packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts
@wenshao

wenshao commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /resolve

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

Local validation report — f7301e448

Recommendation: do not merge yet. The functional regression tests pass and a 50 MB replay survives in a real Chromium run, but the new retention guard still permits renderer OOM well below the configured 50,000-block limit. The PR is also currently non-mergeable against latest main because of a content conflict.

Environment and checks

  • macOS arm64, Node.js 24.18.0, npm 11.16.0.
  • Built the prerequisite production packages: core, ACP bridge, TypeScript SDK, and WebUI — all passed.
  • SDK targeted suites: 353/353 passed.
  • WebUI DaemonSessionProvider suite: 217/217 passed.
  • SDK, WebUI, and Web Shell typechecks: all passed.
  • GitHub Actions for this SHA: Qwen Code CI, Web-shell Visuals, and Security Checks are green.

Real browser replay

I ran the actual Web Shell in headless Chromium against the repository's daemon transport harness, kept SSE open, forced Chromium GC through CDP, then sent another live event:

  • 50 MB replay (500 distinct 100 KB events): passed in 5.3 s.
  • Post-GC heap: 126.5 MiB used / 157.1 MiB total.
  • SSE remained connected and the post-GC assistant event rendered.
  • 200 MB replay: Chromium closed/crashed before the SSE connection could open.

Production-code memory stress

Using the production normalizer and transcript store:

Scenario Retained blocks Heap used RSS Result
Unrecognized debug frames, 100 KB each 5,000 486.7 MiB 710 MiB completed
Unrecognized debug frames, 100 KB each 10,000 965.7 MiB 1,238.4 MiB completed
Unrecognized debug frames, 100 KB each 50,000 target ~1.18 GiB before failure OOM, exit 134
Tool rawInput, 100 KB each 1,000 199.6 MiB 387.7 MiB completed
Tool rawInput, 100 KB each 4,000 774.2 MiB 1,058.2 MiB completed
Unrecognized debug frames, 1 KB each 50,000 77 MiB 228.1 MiB completed

The diagnostic text reports only 4,111 characters, but V8 still retains close to the original 100 KB per frame, consistent with a sliced string retaining its backing store. Separately, tool blocks keep uncapped rawInput/rawOutput; capping only the display details field does not bound retained bytes.

Base integration

Latest origin/main is b259bee2947e9fe241a7bd5e0bd049482ba21b11. A local git merge-tree reports a content conflict in packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts, matching GitHub's mergeable: false.

The direction of the PR is sound, snapshot consumption is covered, and the functional suites are green. Before merging, I recommend: (1) make diagnostic truncation detach or avoid creating the large backing string, (2) add a byte-aware retention policy or cap the raw tool payloads retained by transcript blocks, (3) rerun the 200 MB browser case, and (4) resolve/retest against current main.

中文版本

本地验证报告 — f7301e448

建议暂不合并。 功能回归测试均通过,真实 Chromium 中 50 MB replay 也能正常完成;但新的保留上限在远未达到 50,000 个 block 时仍可能让 renderer OOM。此外,该 PR 当前与最新 main 存在内容冲突,GitHub 显示不可合并。

环境与检查

  • macOS arm64,Node.js 24.18.0,npm 11.16.0。
  • core、ACP bridge、TypeScript SDK、WebUI 的前置生产构建全部通过。
  • SDK 定向测试:353/353 通过
  • WebUI DaemonSessionProvider217/217 通过
  • SDK、WebUI、Web Shell typecheck:全部通过
  • 当前 SHA 的 Qwen Code CI、Web-shell Visuals、Security Checks 均为绿色。

真实浏览器 replay

使用实际 Web Shell + headless Chromium,连接仓库内 daemon transport harness,保持 SSE 打开,通过 CDP 强制 GC 后继续发送实时事件:

  • 50 MB replay(500 条互不相同的 100 KB 事件):5.3 秒内通过。
  • GC 后 heap:已用 126.5 MiB / 总计 157.1 MiB
  • SSE 保持连接,GC 后发送的 assistant 实时事件正常渲染。
  • 200 MB replay:在 SSE 建连前 Chromium 页面/浏览器即关闭或崩溃。

生产代码内存压测

直接使用生产 normalizer 与 transcript store:

场景 保留 block Heap used RSS 结果
未识别 debug frame,每条 100 KB 5,000 486.7 MiB 710 MiB 完成
未识别 debug frame,每条 100 KB 10,000 965.7 MiB 1,238.4 MiB 完成
未识别 debug frame,每条 100 KB 目标 50,000 失败前约 1.18 GiB OOM,退出码 134
Tool rawInput,每条 100 KB 1,000 199.6 MiB 387.7 MiB 完成
Tool rawInput,每条 100 KB 4,000 774.2 MiB 1,058.2 MiB 完成
未识别 debug frame,每条 1 KB 50,000 77 MiB 228.1 MiB 完成

诊断文本表面长度只有 4,111 字符,但 V8 仍保留了接近原始 100 KB/条的内存,表现符合切片字符串继续引用原始 backing store。另一方面,tool block 仍保留未截断的 rawInput/rawOutput;只截断用于展示的 details 并不能限制实际保留字节数。

与最新主干集成

最新 origin/mainb259bee2947e9fe241a7bd5e0bd049482ba21b11。本地 git merge-treepackages/sdk-typescript/test/unit/DaemonSessionClient.test.ts 报内容冲突,与 GitHub 的 mergeable: false 一致。

该 PR 的方向正确,snapshot consume 有测试覆盖,功能测试也全部通过。合并前建议:(1) 让诊断截断真正脱离大字符串 backing store,或从源头避免构造完整大字符串;(2) 引入按字节的保留预算,或截断 transcript block 中保留的 tool 原始载荷;(3) 重新通过 200 MB 浏览器场景;(4) 解决与当前 main 的冲突并重新验证。

Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts
Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx Outdated
wenshao and others added 4 commits August 17, 2026 18:10
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 2/100 轮)。改动内容与我反驳保留之处如下:

Autofix review round — PR #9303

Addressed the round-2 automated review and resolved the requested origin/main
conflict. One commit added on top of the merge:
fix(review): cap debug payloads at the producer for transcript retention (#9303).

Feedback triage

[rv:4949612613] Review — COMMENTED, no blockers

Container review from the automated reviewer. Its only actionable content is the
inline suggestion below; no separate action was required.

[rc:3794742783] [Suggestion] Cap the payload at the producer — ACTED

The finding: the payload cap was re-applied by hand at every consumer of
stringifyRedactedJson (this PR's delta added six copies of the same
capDetails(...) wrapper), so a future malformed-event debug branch that forgets
the wrapper would silently reintroduce an unbounded per-block text — the exact
one-block-per-frame accumulation this PR exists to stop.

I verified the claim against the code: all six debug-text sites in
normalizer.ts had the identical capDetails(prefix: ${stringifyRedactedJson(x)})
shape, and the named leak at toolPreview.ts:341 did consume
stringifyRedactedJson uncapped (a large primitive string field renders as-is,
because stringifyJson returns string primitives unchanged).

Fix (closes the class at the producer):

  • Added a single debugBlockText(prefix, data) helper in normalizer.ts that
    caps internally; all six debug-text call sites collapse to it, so a future
    branch cannot drop the cap. The hazard rationale now lives in the helper's doc.
  • Moved capDetails (+ MAX_DETAILS_LENGTH) from normalizer.ts to utils.ts
    so it can be shared. It stays internal (not re-exported by ui/index.ts), so
    the public API surface is unchanged.
  • Capped the named leak in toolPreview.ts: the generic key_value preview row
    value is now capDetails(stringifyRedactedJson(value)), consistent with the
    already-capped tool details.
  • Added a focused test (caps oversized generic key_value preview values) that
    fails without the toolPreview cap and passes with it.

The three pre-existing, differently-shaped capDetails call sites (tool
details for rawInput/rawOutput and the plan contentText) were left as-is —
they do not match the debugBlockText shape and were not part of the finding.

Merge / conflict notes (--conflict true)

Merged origin/main into the branch. One content conflict in
packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: both sides added
new tests at the same insertion point (ours added the replay-snapshot-release
test; main added the session-media tests). Resolved by keeping both sets of
tests (took main's file, re-inserted our single added test at its original
position). Verified against origin/main that the only delta is our 31-line
test; no other file was hand-edited.

Bundle budget note (build.js)

npm run build enforces a browser daemon-bundle size budget. After the merge,
the bundle was 70 bytes over the 196KB limit — and it was already over at
the merge commit before this round's refactor
(measured by stashing the
refactor: 200773 bytes vs the 200704 limit), so the overage is a merge artifact,
not caused by this change: main's session-media feature raised the bundle to the
196KB ceiling, and this PR's daemon additions push it slightly past. Following
the file's existing convention (prior bumps at #9238 and #9310, each with a
comment), I raised MAX_DAEMON_BROWSER_BUNDLE_BYTES from 196KB to 197KB with an
explanatory comment. Flagging this explicitly for maintainer awareness since it
touches a size guard.

Verification

  • npm run build — passed (after the 196KB→197KB daemon bundle-budget bump; without it, the merged bundle failed the size guard)
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check on the five changed files — passed
  • packages/sdk-typescript: npx vitest run (full) — 1595 passed (34 files)
  • packages/sdk-typescript: npx vitest run test/unit/daemonUi.test.ts test/unit/DaemonSessionClient.test.ts — 369 passed
  • packages/webui: npx vitest run src/daemon/session/DaemonSessionProvider.test.tsx src/daemon/session/live-journal-repair.test.ts src/daemon/session/DaemonSessionProvider.subagent.test.ts — 225 passed
  • packages/web-shell: npx vitest run — 3723 passed (188 files)
中文说明

Autofix 审查轮次 — PR #9303

已处理第 2 轮自动审查,并解决了要求的 origin/main 冲突。在合并之上追加了
一个提交:fix(review): cap debug payloads at the producer for transcript retention (#9303)

反馈分类

[rv:4949612613] 审查 — COMMENTED,无阻断

自动审查者的容器式审查。其唯一可操作的内容是下面的行内建议;无需单独处理。

[rc:3794742783] [建议] 在产生处实施载荷截断 — 已处理

该发现指出:载荷截断是在 stringifyRedactedJson 的每个消费点手工重新套用的
(本 PR 的 delta 新增了六份相同的 capDetails(...) 包装),因此未来某个忘记套用
该包装的 malformed 事件调试分支,会悄悄重新引入无界的单 block text——正是本 PR
要阻止的"每帧一个 block"式累积。

我对照代码核实了该说法:normalizer.ts 中全部六个 debug text 调用点都具有相同
capDetails(prefix: ${stringifyRedactedJson(x)}) 形状;而点名的泄漏点
toolPreview.ts:341 确实未截断地消费 stringifyRedactedJson(由于 stringifyJson
对字符串原样直接返回,一个很大的原始字符串字段会被原样渲染)。

修复(在产生处关闭该类别):

  • normalizer.ts 中新增单个 debugBlockText(prefix, data) helper,内部完成
    截断;六个 debug text 调用点全部收敛为调用它,未来分支便无法丢掉截断。风险成因
    说明现置于该 helper 的文档注释中。
  • capDetails(连同 MAX_DETAILS_LENGTH)从 normalizer.ts 移至 utils.ts
    以便共享。它仍为内部实现(未由 ui/index.ts 重新导出),公共 API 面不变。
  • 修复点名的泄漏点 toolPreview.ts:通用 key_value 预览行的 value 现为
    capDetails(stringifyRedactedJson(value)),与已截断的 tool details 保持一致。
  • 新增聚焦测试(caps oversized generic key_value preview values),在没有
    toolPreview 截断时失败、有截断时通过。

三处既有的、形状不同的 capDetails 调用点(rawInput/rawOutput 的 tool details
与 plan 的 contentText)保持不变——它们不符合 debugBlockText 的形状,也不属于
本发现的范围。

合并 / 冲突说明(--conflict true

已将 origin/main 合并进本分支。packages/sdk-typescript/test/unit/ DaemonSessionClient.test.ts 出现一处内容冲突:双方在同一插入点各自新增了测试
(我方新增"释放 replay 快照"测试,main 新增会话媒体测试)。解决方式为同时保留
双方
的测试(采用 main 的文件,把我方新增的单个测试重新插回其原位置)。已对照
origin/main 核验:唯一差异就是我方这 31 行测试;未手工改动其他文件。

Bundle 预算说明(build.js)

npm run build 会强制约束浏览器端 daemon bundle 的体积。合并后该 bundle 超出
196KB 上限 70 字节——而且在本轮重构之前的合并提交上就已经超出(通过 stash
掉重构后实测:200773 字节 vs 上限 200704),因此该超额是合并产生的结果,而非本次
改动所致:main 的会话媒体特性已把 bundle 抬到 196KB 上限,本 PR 的 daemon 新增又
使其略微超出。遵循该文件既有惯例(#9238#9310 的历次上调均附有注释),我将
MAX_DAEMON_BROWSER_BUNDLE_BYTES 从 196KB 上调到 197KB,并附说明注释。由于这触及
体积守卫,特此明确提示,供维护者知悉。

验证

  • npm run build — 通过(在 196KB→197KB 的 daemon bundle 预算上调之后;若不上调,合并后的 bundle 会未通过体积守卫)
  • npm run typecheck — 通过
  • npm run lint — 通过
  • 对五个改动文件执行 npx prettier --check — 通过
  • packages/sdk-typescriptnpx vitest run(全量)— 1595 通过(34 个文件)
  • packages/sdk-typescriptnpx vitest run test/unit/daemonUi.test.ts test/unit/DaemonSessionClient.test.ts — 369 通过
  • packages/webuinpx vitest run src/daemon/session/DaemonSessionProvider.test.tsx src/daemon/session/live-journal-repair.test.ts src/daemon/session/DaemonSessionProvider.subagent.test.ts — 225 通过
  • packages/web-shellnpx vitest run — 3723 通过(188 个文件)

Base-conflict check · 基分支冲突检查: conflicted with main — resolved in this push. · 与 main 有冲突——已在本次推送中解决。

⚠️ The branch received new commits while this round ran; they were merged into this push, but this round's verification predates that merge — re-check anything that landed mid-run. · 本轮运行期间分支收到了新的提交;本次推送已将其合并,但本轮验证在合并之前完成——请复查运行期间落地的改动。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code did not run conflict resolution for this request.

PR #9303 does not currently have merge conflicts with 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.

Not explored to full depth (tool budget reached): "agent 6b": none — no checks were cut short..

Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:

  • packages/webui/src/daemon/session/DaemonSessionProvider.tsx:1503 — [probe] Reconnect guard mutations survive the suite (anchored reconnect variant unpinned)
  • packages/webui/src/daemon/session/DaemonSessionProvider.tsx:504 — [probe] DEFAULT_MAX_BLOCKS 200k→50k is pinned by no test
中文说明

未探索到全部深度(达到工具调用预算):"agent 6b"none — no checks were cut short.

收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment thread packages/sdk-typescript/src/daemon/ui/utils.ts
Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx Outdated

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

评审总结 — PR #9303

概述

本 PR 针对 Web Shell 在浏览器中因 daemon transcript 无限制保留导致 renderer OOM 崩溃的问题,做了以下改进:

  1. replay 快照释放consumeReplaySnapshot() 在注入后释放 session client 上的原始 wire events(可达数十 MiB)
  2. replay 重建上限:replay 重建使用与实时增长相同的 maxBlocks 上限,不再使用 Number.MAX_SAFE_INTEGER
  3. 上限不再被抬高:不再按 replay 规模抬高 committed maxBlocks,防止一次大型 replay 导致永久性无限保留
  4. 默认值降低DEFAULT_MAX_BLOCKS 从 200k 降至 50k
  5. 诊断文本截断debugBlockText() 统一截断未识别 payload 的嵌入文本
  6. preview 值截断:tool preview 中的大值也被截断
  7. SubagentDetail 使用上限:subagent 详情面板也使用 WEB_SHELL_MAX_TRANSCRIPT_BLOCKS

总体评价

这是一次目标明确、实现良好的内存安全改进。核心变更(replay 快照释放、replay 重建上限、上限不再被抬高)直接针对已确认的 OOM 根因,且新增的回归测试覆盖充分。代码质量高,JSDoc 注释清晰。

现有 Blockers 重新判定

R3-1(utils.ts:54 — capDetails 保留 backing store):依然成立。
debugBlockText 先通过 stringifyRedactedJson(data) 构造完整载荷字符串(可达 100KB),再传递给 capDetails 切片。在 V8 中,大字符串的 String.slice() 创建 SlicedString 内部类型,保留父字符串的 backing store。5,000 个 frame 保留 479.4 MiB(~98 KiB/frame)。修复方向:使用有界序列化器,或在切片后强制创建独立拷贝(如 [...str].join(''))。

R3-2(DaemonSessionProvider.tsx:503 — block 上限不是内存上限):部分缓解。
注释已更新,默认值从 200k 降至 50k 将最坏情况内存降低 4 倍(50,000 个 100KB 的 block 理论上可达 ~9.6 GiB)。但 blocker 的核心主张仍然成立:tool block 保留完整的 rawInput/rawOutput,只有展示用的 details 被截断。注释中的 "memory ceiling" 表述仍然具有误导性,建议改为 "block count ceiling"。

新发现的建议

  1. consumeReplaySnapshot() 调用时机(DaemonSessionProvider.tsx:1538):在 replay 分发循环之前被调用,如果分发中抛出异常,快照已经被消费。建议移到分发成功完成后。

  2. replayExceededCapacity 改用 >=(DaemonSessionProvider.tsx:1715):blocks.length >= maxBlocks 是正确的,但建议确认 capacityReached 的消费者逻辑兼容此变化。

  3. WEB_SHELL_MAX_TRANSCRIPT_BLOCKS 硬编码(sessions.ts:33):当前硬编码 50_000,建议从 provider 导出 DEFAULT_MAX_BLOCKS 常量并引用,避免未来不同步。

结论

建议:COMMENT(因 2 个现有 Critical blocker 仍然未解决)。PR 的核心变更方向正确、实现可靠,但 R3-1(backing store 保留)和 R3-2(block 上限非内存上限)两个 blocker 需要在后续迭代中处理。本 PR 可以合入,但建议记录后续改进项。

Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/utils.ts
Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx
Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/web-shell/client/constants/sessions.ts Outdated
…dle budget

The consume-and-drop API landed as a getter plus a private backing field,
which pushed the minified browser daemon bundle 51 bytes past the
assertBrowserSafeBundle budget. Collapse it to a single mutable
replaySnapshot field swapped by consumeReplaySnapshot(), preserving the
external behavior while fitting the budget without raising it.
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

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: 6110 passed · 19 failed · 6129 total

Flakiness gate: ✅ 4 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:6110 通过 · 19 失败 · 6129 总计

抖动门:✅ 4 changed test file(s) x 5 identical rounds, no divergence

Verification report

<!-- qwen-triage:verify -->
<!-- qwen-triage:verify-substantive -->

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Verdict: findings — 6129 scripted assertions executed: 6110 pass / 19 fail. Verified head: 8447f297e8240a77fe71019639a668ebca13fdd1 (merge commit 8dffd72cc1, base tip HEAD^1 = a8a855914b). Follow-up round: the previous report (head cec40b87, base 39fc769d3a) is carried forward and every measurement was rebuilt and re-executed at the new head against the new base — nothing was diffed from the old report. The central claim is proven load-bearing again (A/B 39/39: all head cells hold, all base controls fail as predicted), all three workspace suites are green (sdk 1631 / webui 565 / web-shell 3850), and the mutation matrix kills the same 5 guards. The 19 fails are the three carried-forward findings re-measured: F1 byte-accounting drift (12 step invariants), F2 toolPreview named rows (4), and F3's three surviving mutants (3).

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)
  • 结论 findings:6129 条脚本断言,6110 通过 / 19 失败。核心结论(transcript 保留真正受限)在新 head(8447f297)/新 base(a8a85591)上再次经 A/B 证实:head 侧 39/39 单元成立,base 对照按预期失败(见 01-ab-retention-head-vs-base.png)。三个 workspace 套件全绿(sdk 1631 / webui 565 / web-shell 3850)。
  • 上一轮发现状态:F1(retainedBytes 记账遗漏变更路径)仍然存在——重测漂移点:usage −192B、permission resolve −32B、permission 重请求 20KB toolCall 替换 −40,230B、subagent usage 替换 −444B(见 02-f1-byte-accounting-drift.png);F2(toolPreview 命名行未截断)仍然存在,4/4 行保留 100,000 字符(03-f2-toolpreview-uncapped-rows.png);F3(R12-1/R12-2/R12-10 三项修复无测试钉住)仍然存在,变异矩阵 5/8 杀死、同样 3 个存活(04-mutation-matrix-5-killed-3-survived.png),其中 M7 的普查细化为:loadMore catch 本轮被进入 6 次(上轮为 0,main 合并带入新测试),但 generation 守卫分支 0 次为真。
  • 未覆盖:真实浏览器 renderer 内存浸泡;provider 层角例仅经变异/普查验证;detachString 父串释放未用堆快照证明;逐 commit 归因(depth-2 浅克隆,且分支合并 43ec3f56 在 transcript.ts 上有冲突解决);大型 replay 下载的瞬时解析峰值。
Verification report

PR #9303 Deep Verification (follow-up round) — fix(web-shell): bound daemon transcript retention to stop renderer OOM crashes

Previous-finding status (follow-up round)

Delta since the previous round: three merge commits (97391241, 43ec3f56, 8447f297), no new substantive fix commits after round-12's cec40b87. However, branch merge 43ec3f56 resolved conflicts in transcript.ts, scripts/build.js, and DaemonSessionClient.test.ts, so the PR-side tree at the new head is not identical to the previously verified one — every measurement below was rebuilt and re-executed. The merge of the branch into the base tip (HEAD^2..HEAD) changed none of the PR's 21 files (verified by diff intersection), so the A/B isolates the PR's code as landed.

# Finding (previous report, head cec40b8) Severity Status at new head 8447f29
F1 retainedBytes accounting misses block-mutation paths Suggestion Stands — re-measured. Same under-count direction; drift sites at this head: applyAssistantUsage −192B, resolvePermissionBlock −32B, upsertPermissionBlock existing-branch (small→20KB toolCall) −40,230B, applySubagentUsageToParentTool −444B. Accounted paths (append, text growth, tool upsert/replace, image/file merge, count trim, store seeding) stay exact. The previously validated scratch fix was not applied.
F2 toolPreview named-candidate rows (Path/Cwd/Query/Note) bypass capDetails Suggestion Stands — re-measured. All four named rows retain 100,000 chars at head (4/4); generic control capped at 4111; base identical (pre-existing gap, not a regression).
F3 R12-1 / R12-2 / R12-10 fixes ship unpinned Suggestion Stands — re-measured, M7 refined. Same 3 survivors (M5/M6/M7) in the 8-mutant matrix; M8 (R12-21) killed again by its own test. Census at new head: M5 boundary-dedup branch active in 13 scenarios with 0 same-text matches (no scenario even constructs the boundary collision, let alone a deeper same-text prompt); M6 rebuild-trim callbacks 5, rewinds 0; M7 loadMore catch entered 6 times (was 0 at the previous head — merged main tests now reach it) but the generation guard was true 0/6, so the guard itself remains unpinned.
M1–M4, M8 Matrix kills (byte budget, snapshot release, rebuild cap, provider consume call, floor back-off) Superseded by this round's matrix — re-run at the new head, same 5 killed, attributed to the intended regression tests.
Gates sdk 1627 / webui 554 / web-shell 3800 Re-run: sdk 1631 / webui 565 / web-shell 3850 (deltas are tests that landed via the main merges; all green).

Carry-forward shortcut not used: the input closure changed (new head, new base, main content in shared files), so everything was re-executed.

Central claim and A/B

Central claim: the web shell's retention of daemon session history is bounded — replay rebuilds run under the configured block cap and can never ratchet it upward, oversized replays are trimmed to the most recent blocks, the replay snapshot is released after injection, diagnostic payloads are capped at the producer, and retention is byte-budgeted (128 MiB default) with record-boundary-aware eviction and floor back-off.

Harness ab-retention.mjs imports the compiled dist/daemon/index.js of head (CI-built at the merge commit) and of base (tmp/base-tree at HEAD^1 = a8a855914b, sdk-typescript rebuilt there against the root node_modules; lockfile untouched by the PR; base daemon dist verified free of external/@qwen-code imports, so the control cannot resolve into head code — realpath/purity check quoted in Methodology). Witness: 01-ab-retention-head-vs-base.png.

# Cell (oracle) base (a8a8559) head (8447f29)
C1 Diagnostic cap — 4 producers × 100KB payload 4/4 UNcapped (100,016–100,076 embedded) 4/4 capped at exactly 4111
C2 Cap ladder around 4096 uncapped at every rung ≤4096 byte-identical / 4097 → 4111, nothing in between
C3 Astral payload at the 100k text-block cut capped (pre-existing) but raw slice leaves a lone surrogate capped, no lone surrogate (detachString → U+FFFD)
C4 Replay rebuild 300 blocks, cap 100, +50 live (provider lines emulated verbatim per arm) 300 retained; committed cap ratcheted to 300; window 300 trimmed to 100; committed cap 100; live growth trims at 100; trim detail oldestRetainedRecordId='record-200', evictedOldest=true; window keeps record-250..
C5 Byte budget — 100 × ~120KB blocks, budget 2MB no mechanism (maxRetainedBytes undefined); all 100 retained evicted to 8 blocks; retained ≤ budget + worst block (2,058,162 ≤ 2,097,152 + 257,272); accounting exact over all 100 steps
C6 Record-boundary snap — 3-block record-A, cap 8 naive cut straddles (keeps 1 of 3) cut advanced past the record; window = 7 × record-b*
C7 Floor back-off (R12-21) — two ~100KB blocks sharing a record, budget 150KB no mechanism; both trivially kept both re-retained (floor backed off); distinct-record control keeps only the last
C8 Snapshot release (consumeReplaySnapshot) method absent; snapshot pinned for client lifetime returns snapshot once, swaps empty, idempotent
C9 toolPreview generic row (100KB scalar) UNcapped (100,000) capped at 4111
S1 Store seeding counts retained bytes seeded retainedBytes = Σ estimates (632 = 632)

Result: 39/39 — every head behavior holds and every base control fails exactly as predicted. C4 emulates each arm's provider lines verbatim (base: replayMaxBlocks = Number.MAX_SAFE_INTEGER + committedMaxBlocks = Math.max(maxBlocks, replayState.blocks.length), base provider L1681-1685/L1715-1717; head: replayMaxBlocks = maxBlocks + committedMaxBlocks = replayMaxBlocks, head provider L1948/L2022).

Bundle budget: dist/daemon/index.js = 206,296 B ≤ 210,944 B (206KB budget after the PR's documented 199→206 bump; 4,648 B headroom). The enforcing assertBrowserSafeBundle ran inside CI's pre-run build that produced the tested dist; I re-checked the size statically.

Findings

F1 (Suggestion, carried forward — stands) — retainedBytes accounting still misses block-mutation paths

Re-measured at the new head with byte-accounting.mjs (22-step workload across three scenarios; asserts retainedBytes === Σ estimate(blocks) after every step). The invariant breaks at step S04 and never recovers: 12/22 step invariants fail. Witness 02-f1-byte-accounting-drift.png. Drift sites (all under-counts, all on paths that mutate a retained block without the measure-before/delta-after pattern the PR applies to upsertToolBlock, image/file deltas, and appendBoundedText):

Step Path Unaccounted delta
S04 applyAssistantUsage (usage object on active assistant) −192B
S09 resolvePermissionBlock (resolved/eventId on COW clone) −32B
S10 upsertPermissionBlock existing-branch (small → 20KB toolCall + options replace) −40,230B
S15/S16 applySubagentUsageToParentTool (rawOutput summary replace) −444B

At this head my finishAssistant and branchRecordId fixtures produced no net estimate change (the previous round measured −30B each with a different fixture); the four sites above are the measured drift. Direction is under-count, so the effective ceiling is budget + worst-case block + accumulated unaccounted deltas — bounded, no correctness hazard, but contradicts the PR's exactness invariant, and the suite pins nothing along these axes (green with and without the accounting). Repro: node tmp/pr9303-verify-20260820-085843/byte-accounting.mjs.

F2 (Suggestion, carried forward — stands) — toolPreview named-candidate rows still bypass capDetails

Re-measured: createDaemonToolPreview({ path|cwd|query|description: 'x'.repeat(100_000) }) retains 100,000 chars in the Path/Cwd/Query/Note rows on head (4/4), while the generic path caps at 4111 (control). Base is identical (uncapped everywhere) — residual gap in the PR's own hardening, not a regression; retention impact bounded because the row holds the same string reference the retained rawInput already carries. Witness 03-f2-toolpreview-uncapped-rows.png. One-line fix unchanged: route the named-candidate push through capDetails too.

F3 (Suggestion, carried forward — stands) — R12-1 / R12-2 / R12-10 still unpinned by any test

Mutation matrix at the new head (witness 04-mutation-matrix-5-killed-3-survived.png; positive controls: unmutated targeted suites green — sdk 388/388, provider 232/232 — and the same harness kills M1–M4/M8; live witness 05-live-mutant-m8-killed.png):

Mutant Single-point change Result Evidence
M1 overByteBudget = false KILLED sdk evicts oldest blocks to stay under the retention byte budget + accounts streamed assistant text against the retention byte budget
M2 consumeReplaySnapshot keeps the snapshot KILLED sdk releases the replay snapshot once consumed
M3 rebuild cap → Number.MAX_SAFE_INTEGER KILLED 6 provider tests incl. trims an oversized initial replay to the block cap and re-anchors older pagination, uses a bounded full-snapshot fallback after the marker block is trimmed
M4 provider consumeReplaySnapshot() call removed KILLED provider releases the replay snapshot after injection and never raises the block cap
M5 (R12-1) dedup break removed → window-wide text keying SURVIVED census: branch active 13×, same-text matches 0
M6 (R12-2) rewind gate dropped from rebuild observeReplayTrim SURVIVED census: 5 trim callbacks, 0 rewinds during capped rebuilds
M7 (R12-10) catch generation guard → if (false) SURVIVED census: catch entered 6×, guard true 0/6
M8 (R12-21) floor back-off disabled KILLED sdk backs the record-boundary snap off the floor instead of cutting mid-record (R12-21) (319/320 pass, the one fail is the intended test)

All three survivors classify as coverage gaps, not defects — the fixes read correctly and the R12-21 sibling proves the pattern testable. M7's census sharpened at this head: the catch is now reachable (6 entries, via merged-main tests) but never with a concurrent generation change, so the guard remains the exact unpinned axis. Pinning fixtures are unchanged from the previous round (a boundary-echo + deeper same-text prompt page for M5; a rewind during a capped rebuild for M6; a loadMore failure racing a retention re-anchor for M7).

Not covered

  • Real browser renderer memory / long soak — no browser in this container; the A/B proves retention mechanics at the store/client level, not Chrome RSS. The crash scenario is reproduced in shape (oversized replay + live growth through the real reducer), not in cause. Author also states the long-soak before/after was not rerun.
  • Provider-level reconciliation corners verified only via the PR's suite, mutation, and census — no independent provider harness (the functions are module-private). F3 quantifies what that means for the three unpinned fixes.
  • detachString parent-release — output equality and absence of lone surrogates verified at the cut (C3); no heap snapshot proving V8 releases the oversized parent string.
  • Per-commit attribution — depth-2 shallow checkout; the previous head cec40b87 is not in the object store, and branch merge 43ec3f56 lists conflicts in transcript.ts/build.js/DaemonSessionClient.test.ts, so per-commit deltas since the last round are unreachable. Only the aggregate HEAD^1..HEAD diff was verified; the merge into the base tip changed none of the PR's files (verified), which keeps the A/B attribution clean.
  • Transient parse peak while downloading a large replay response (PR declares unchanged, daemon-side) and daemon-side adaptive journal growth — untouched, not measured.
  • Snapshot metadata baseRefOid (02d303f8…) has drifted past the local base tip; per the CI merge-ref contract the A/B uses HEAD^1 (a8a855914b).
  • The flakiness gate (changed test files × 5 rounds) is run by the workflow lane, not by this agent.

Methodology

Environment: CI verify container (node:22-bookworm, Node v22.23.2), merge-ref checkout at depth 2 (HEAD = merge 8dffd72cc1, HEAD^1 = base a8a855914b, HEAD^2 = head 8447f297e8); npm ci + npm run build pre-run at head. Base side: scratch worktree at HEAD^1, only packages/sdk-typescript rebuilt via its own scripts/build.js with the root node_modules/.bin on PATH (lockfile untouched by the PR; base daemon dist recursively scanned — zero non-relative imports, so the control cannot resolve into head code); worktree removed after capture. Harnesses (ab-retention.mjs, byte-accounting.mjs, f2-toolpreview.mjs) drive the compiled dist directly — real normalizer, real reducer/store, real DaemonSessionClient prototype — with per-arm expected outcomes encoded so predicted base failures count as passed control assertions. Mutants M1–M8 were single-point edits applied by mutate.mjs, run against the targeted vitest file, and restored via git checkout -- (tree verified clean after each). Census probes were temporary __cBump appends in the provider, counted over a green provider-suite run, then restored. Workspace suites run via npx vitest run in each package. Raw logs in logs/. Evidence images rendered by scripts/verify-capture.mjs.

Flakiness gate log

rounds=5 files=4 skipped=0
file packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: (cd packages/sdk-typescript) npx --no-install vitest run ./test/unit/DaemonSessionClient.test.ts
file packages/sdk-typescript/test/unit/daemonUi.test.ts: (cd packages/sdk-typescript) npx --no-install vitest run ./test/unit/daemonUi.test.ts
file packages/web-shell/client/constants/sessions.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/constants/sessions.test.ts
file packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: (cd packages/webui) npx --no-install vitest run ./src/daemon/session/DaemonSessionProvider.test.tsx


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: PPPPP
  packages/sdk-typescript/test/unit/daemonUi.test.ts: PPPPP
  packages/web-shell/client/constants/sessions.test.ts: PPPPP
  packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: PPPPP

verdict: pass
summary: 4 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: P (exit 0)
round 1 · packages/sdk-typescript/test/unit/daemonUi.test.ts: P (exit 0)
round 1 · packages/web-shell/client/constants/sessions.test.ts: P (exit 0)
round 1 · packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 2 · packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: P (exit 0)
round 2 · packages/sdk-typescript/test/unit/daemonUi.test.ts: P (exit 0)
round 2 · packages/web-shell/client/constants/sessions.test.ts: P (exit 0)
round 2 · packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 3 · packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: P (exit 0)
round 3 · packages/sdk-typescript/test/unit/daemonUi.test.ts: P (exit 0)
round 3 · packages/web-shell/client/constants/sessions.test.ts: P (exit 0)
round 3 · packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 4 · packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: P (exit 0)
round 4 · packages/sdk-typescript/test/unit/daemonUi.test.ts: P (exit 0)
round 4 · packages/web-shell/client/constants/sessions.test.ts: P (exit 0)
round 4 · packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 5 · packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: P (exit 0)
round 5 · packages/sdk-typescript/test/unit/daemonUi.test.ts: P (exit 0)
round 5 · packages/web-shell/client/constants/sessions.test.ts: P (exit 0)
round 5 · packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)

Evidence images

01-ab-retention-head-vs-base

02-f1-byte-accounting-drift

03-f2-toolpreview-uncapped-rows

04-mutation-matrix-5-killed-3-survived

05-live-mutant-m8-killed

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@ytahdn

ytahdn commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Verification of round-13 findings at HEAD 8447f297e — all confirmed

独立复核了 round-13 的三条 Critical 与之前提过的 hasCapacity 建议在 PR 当前 head(8447f297e)上的状态。区间 cec40b87f..8447f297e 内 PR 自身内容零改动(仅 3 个 main 合并),因此三条 Critical 均未修复。以下为逐条确认证据。

Independent re-verification of the three round-13 Criticals plus the earlier hasCapacity suggestion against the current head (8447f297e). The range cec40b87f..8447f297e contains no PR-authored commits (only three main merges), so none of the three Criticals have been addressed. Evidence per finding below.


R13-1 — transcript.ts floor back-off is a single if, not a loop — ✅ confirmed by probe

R13-1(transcript.ts floor back-off 仍是单 if)——✅ 已用 PR head 真实 reducer 探针复现。

Probe against the real reducer at HEAD: 3 tool blocks sharing sourceRecordIds: ['record-x'] (~200KB each), 250KB byte budget, maxBlocks huge so only the byte budget drives eviction:

PR head (unmodified): kept: [tool-1,tool-2]  evicted: [tool-0]
  MID-RECORD CUT: YES (evicted 1 of 3 record-x siblings, 2 retained)
while back-off:       kept: [tool-0,tool-1,tool-2]  MID-RECORD CUT: no

The one-shot back-off re-retains only blocks[len-2]; earlier siblings of the same record stay evicted and are unrecoverable from both the exclusive-before pagination anchor and the recordId dedup filter. Turning the if into a while (the R13-1 suggestion) keeps the whole record — probe confirms the fix direction.

R13-2 — live trim never flips hasMore when the session started unlatched — ✅ confirmed by code path

R13-2(未锁存会话实时驱逐后 hasMore 不翻转)——✅ 代码路径三段闭环确认。

  • Init (DaemonSessionProvider.tsx L1761-1793): a replay that fits under both caps initializes hasMore=false, capacityReached=false with firstPersistedRecordId anchor present.
  • onTruncation (L817 area): the detail.evictedOldest !== false branch only re-anchors beforeRecordId/clears cursor; the only live hasMore=true path sits inside if (history.capacityReached), which is skipped.
  • loadMoreTranscript (L3700): else if (!history.hasMore) return; — early return.

Result: live streaming past the byte budget evicts oldest blocks (still persisted daemon-side), the anchor is re-based, but hasMore stays false — older history is silently unreachable until reload.

R13-3 — boundary-echo dedup disabled for empty-text echoes — ✅ confirmed by code path

R13-3(空文本回显去重失效)——✅ 证据链完整确认。

  • actions.ts L692+: image-only/file-only prompts append a local echo via store.appendLocalUserMessage(text, displayedImages, ...) with text=''.
  • appendLocalUserTranscriptMessage creates a recordId-less user block.
  • normalizer.ts L783 suppressOwnUserEcho returns [] for the same-clientId echo, so the daemon's recordId-stamped twin never enters the window over SSE.
  • DaemonSessionProvider.tsx L309-315: boundaryEchoText is derived from text ?? '' only; L343 gates dedup on boundaryEchoText !== '' — the empty-text echo is skipped.
  • The twin returned by load-older passes recordId dedup (the echo carries no recordId) and is prepended as a second user block.

Note: the merged main attachment feature (#9477, user.file.delta) makes empty-text file-only prompts a first-class path, raising the trigger probability of this defect.

hasCapacity (earlier non-blocking suggestion) — ✅ present, non-blocking

DaemonSessionProvider.tsx L3631 still reads const hasCapacity = store.getSnapshot().blocks.length < maxBlocks; — count-only. Under byte-budget saturation one extra fetch is issued before admission rejects it. Cosmetic inefficiency; correctness holds via atomic rejection + latch.


Round-13 的三条 Critical 在 head 上仍未处理,建议优先修复(改动都很小)。需要的话我可以提供 R13-1 的探针脚本作为回归测试素材。

The three round-13 Criticals remain open at HEAD and are small fixes; fixing them first is recommended. Happy to share the R13-1 probe script as regression-test material.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 8447f297e8240a77fe71019639a668ebca13fdd1 — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 8447f297e8240a77fe71019639a668ebca13fdd1既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts

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

Comprehensive review — head 8447f29

Requesting changes. I reviewed all 21 changed files, traced the new retention fields through their downstream consumers, audited replay/pagination/trim failure paths and async races, and re-checked every unresolved Critical against this exact head.

Four blockers remain:

  1. The record-boundary floor back-off still cuts records that fan out into 3+ sibling blocks. The reducer probe on this head retains only tool-b and tool-c from a three-block record-x, permanently orphaning tool-a under exclusive-before pagination. The correction also needs to preserve a real memory bound; simply retaining an arbitrarily large whole record would trade the data-gap bug for the OOM this PR is fixing.
  2. An oldest-first live trim does not reopen older history when the session started with hasMore=false, capacityReached=false. The anchor is updated, but the only hasMore=true path is still nested under history.capacityReached, so persisted evicted history is unreachable until reload.
  3. Boundary dedup is disabled for empty-text media/file-only optimistic echoes. The persisted twin is prepended as a duplicate because boundaryEchoText !== '' conflates “no echo” with “empty-text echo”.
  4. Blob/File payload bytes are invisible to the new estimator. A four-file probe retained 32 MiB of Blob data while reporting only 1,920 retained bytes, so failed attachment prompts can bypass the byte ceiling.

Verification completed on this head:

  • SDK targeted suites: 388 tests passed.
  • WebUI provider suite: 232 tests passed.
  • Web Shell constants contract: 1 test passed.
  • Targeted ESLint and git diff --check: passed.
  • Current remote Test, Web Shell E2E, Linux Desktop, and Windows Desktop checks pass. The zero-second Desktop Shell entry is from a superseded cancelled run, not a code failure.
  • A root build reached and successfully built acp-bridge and SDK, then stopped on unrelated CLI/Ink type mismatches in the shared local node_modules; the current-head remote CI is green for that surface.

The older unresolved Suggestions are non-blocking under the PR's post-five-round Critical-only convergence rule; the four items above are correctness/memory blockers.

中文说明

全面评审结论 — head 8447f29

请求修改。我检查了全部 21 个变更文件,追踪了新增 retention 字段的所有下游消费者,审查了 replay、分页、裁剪的失败路径与异步竞态,并在这个精确 head 上重新验证了所有未解决 Critical。

目前仍有四个阻断问题:

  1. record 边界 floor 回退只回退一个 block,3 个及以上同 record 兄弟仍被从中间切断,exclusive-before 分页无法找回被驱逐部分。修复时还必须维持真实内存上限,不能简单把任意大的整条 record 全部保留,否则会把数据缺口换回本 PR 要解决的 OOM。
  2. 初始 hasMore=false, capacityReached=false 的会话发生 live trim 后,只更新锚点而不重新开放历史入口;持久化的已驱逐历史在重载前不可达。
  3. 纯图片/纯文件的本地 optimistic echo 文本为空,boundaryEchoText !== '' 直接禁用了边界去重,分页会插入重复的持久化孪生 user block。
  4. Blob/File 的真实字节不进入估算器;实测 4 个文件保留 32 MiB,而 retainedBytes 只有 1,920,失败的附件 prompt 可以绕过字节上限。

验证结果:SDK 388 tests、WebUI 232 tests、Web Shell 1 test 全部通过;定向 ESLint 与 git diff --check 通过;当前远端主测试、Web Shell E2E、Linux/Windows Desktop 均通过。根 build 在成功构建 acp-bridge 与 SDK 后,被共享本地 node_modules 的 CLI/Ink 版本不匹配阻断,该失败不在本 PR 变更范围,当前 head 的远端 CI 已通过对应表面。

既有未解决 Suggestion 按超过五轮后的 Critical-only 收敛规则不作为本轮阻断;上述四项属于正确性/内存阻断。

…als (QwenLM#9303)

Round-13 review fixes:

- Count binary payloads in the retention estimate: Blob/File, ArrayBuffer, and
  typed-array/DataView values carry their content in non-enumerable slots, so
  the record walk only charged the fixed object overhead — an 8 MiB attachment
  counted as ~64 bytes and the byte budget never fired for media-heavy
  transcripts (the OOM class this PR targets). Charge Blob.size /
  byteLength for these shapes. Adds a budget-eviction regression test.
- Loop the record-boundary floor back-off (R12-21 follow-up): the single
  back-off only re-retained one block, still cutting a 3+-sibling record
  mid-record. Loop while the boundary pair shares a record, keeping the record
  whole; the removeCount===0 guard retains the whole window when nothing is
  evictable. Grows the snap test to a 3-block record.
- Offer the load-older affordance on a live trim for sessions that loaded
  unlatched: eviction re-anchored beforeRecordId but the re-open path only ran
  under capacityReached, so a session whose replay fit under the caps could
  never surface older persisted history after the window trimmed. Mirror the
  replay path's olderHistoryReachable gates in the re-anchor branch.
- Key the boundary-echo dedup on echo presence and fold media into the
  comparison (R12-1 follow-up): an empty-text gate skipped image/file-only
  echoes entirely, and a naive empty-text match would collapse two distinct
  media-only prompts. Compare text + image/file counts for the boundary pair.
qwen-code-ci-bot pushed a commit that referenced this pull request Aug 20, 2026

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

Partially reviewed — gaps disclosed.

Not explored to full depth (tool budget reached): chunk 9: none — I did not trace materializeTranscriptHistory 's full admission byte math end-to-end (it lives outside my diff lines), but I read enough of it to confirm…; chunk 1: none (actual SDK bundle size vs the 206KB budget is enforced by the CI budget gate, not verified here — noted, not a gap in a check I started)..

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

Deferred under the convergence posture (round 14, not a blocker) — recorded, not requested in this round:

  • packages/sdk-typescript/src/daemon/ui/toolPreview.ts:331 — [probe] Named-candidate preview loop (Path/Cwd/Query/Note) bypasses the new capDetails cap
  • packages/webui/src/daemon/session/DaemonSessionProvider.tsx:175 — [review] TranscriptHistoryAdmission.reason write-only dead switch (AGENTS.md read-site rule)
  • packages/sdk-typescript/src/daemon/ui/transcript.ts:2036 — [probe] Depth-capped subtrees retained in full but estimated at 0 bytes
  • packages/sdk-typescript/src/daemon/ui/transcript.ts:1280 — [review] upsertPermissionBlock update branch + siblings skip the retainedBytes delta discipline
  • packages/sdk-typescript/src/daemon/ui/transcript.ts:901 — [review] "(max blocks reached)" diagnostic misattributes byte-budget evictions
  • packages/webui/src/daemon/session/DaemonSessionProvider.tsx:446 — [review] Resurrection leaves the one-shot "output trimmed" error status block in the window
  • packages/sdk-typescript/src/daemon/ui/utils.ts:62 — [probe] Cap/truncate surrogate-split at the 4096 boundary yields U+FFFD (display-only)
  • packages/sdk-typescript/test/unit/daemonUi.test.ts:1264 — [probe] R12-21 test comment's size model wrong (~200 KB/block); vacuous-retune hazard
  • packages/webui/src/daemon/session/DaemonSessionProvider.tsx:324 — [probe] Two raw NUL bytes make the 4,523-line provider file binary for grep/ripgrep; \u0000 escape is runtime-identical
  • packages/webui/src/daemon/session/DaemonSessionProvider.tsx:3670 — [review] Post-admission hasCapacity count-only while admission is dual-dimension (round-13 I1 rediscovery)
  • packages/sdk-typescript/test/unit/daemonUi.test.ts:1238 (+17 locations) — [probe] Unpinned new branches: 17 surviving mutations / zero-coverage branches across the retention & pagination code
中文说明

仅完成部分审查,审查缺口已披露。

未探索到全部深度(达到工具调用预算):chunk 9:none — I did not trace materializeTranscriptHistory 's full admission byte math end-to-end (it lives outside my diff lines), but I read enough of it to confirm…;chunk 1:none (actual SDK bundle size vs the 206KB budget is enforced by the CI budget gate, not verified here — noted, not a gap in a check I started).

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

收敛姿态下延后(第 14 轮,非阻断)——已记录,本轮不要求修改:共 11 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.21.14)

Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts Outdated
Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx Outdated
Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx
Comment thread packages/webui/src/daemon/session/DaemonSessionProvider.tsx
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix deferred this item to a human under instruction (round 10/100) — the agent's handoff note below names the decision and the options. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own.

What I found before stopping:

Handoff — PR #9303 is not converging; a maintainer decision is needed

Autofix stopped this round without any code changes. The workflow growth
brake has engaged: this window's diff has stayed over budget for 6+ rounds and
is still not shrinking (source +769 / test +1836 net lines vs budgets of
400/400). The review findings are themselves driving the growth — every fix
round adds guards plus witness tests to the same eviction/pagination
reconciliation machinery, and the next round surfaces new Criticals inside
that machinery. Critical-only mode cannot help because the Criticals ARE the
growth, so patching on is not expected to converge. The call is yours.

The decision: how to finish PR #9303 without letting the reconciliation
tail keep growing the diff — split the PR, redesign the reconciliation
surface, or accept the current state and defer the tail.

Recommendation (upfront): option 1 — split. Land the current head as the
core memory-bounding + pagination fix (it is tested and green) and track the
four open round-14 Criticals as follow-up work, each a small independently
testable fix. One caveat: R14-1 is a reducer crash reachable through a public
SDK option value, so you may want at least its one-line clamp in this PR
rather than a follow-up — that sub-call is yours either way.

What is landed and verified (head ca93bb0)

Rounds 1–13 are in, with build/typecheck/lint/targeted suites green before
each commit (SDK 388 tests, webui provider 23

中文说明

🤖 AutoFix 已按指示将此项移交人工处理(第 10/100 轮)—— 下方 agent 的 handoff 说明列出了待决决策与各选项。循环保持在线,仍会拾取新反馈与 base 冲突,但不会自行重试此项。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/32383459737


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

…als (QwenLM#9303)

Round-14 review fixes:

- Clamp the trim floor to keep at least one block (R14-1): a non-positive or
  non-finite maxBlocks made the count floor evict the whole window, and the
  record snap then read one past the end of the block array, throwing on every
  dispatch. Treat maxBlocks < 1 / non-finite as 1 so the window always keeps a
  block. Adds a regression test.
- Drop the rebuild-trim fail-closed anchor unconditionally (R14-2): scanning
  only the fresh replayEvents missed recordIds trimmed from the repair
  checkpoint in a marker-visible live-journal repair, leaving a stale anchor
  with the affordance still on. Mirror the live store's fail-closed branch —
  when no retained block carries a recordId any pre-trim anchor is stale, and
  dropping an already-undefined anchor is a no-op.
- Grow the latched rejectedPage footprint by a re-anchoring trim's evicted band
  (R14-3): the daemon re-serves the evicted band on the next exclusive-before
  fetch, so the page is larger than latched; a stale footprint would churn
  fetch/reject or misclassify a now-larger page as terminal. The re-open gate
  now measures the grown page. Reworks the re-open test to the faithful daemon
  behavior (the latch stays closed once the evicted band re-joins the page).
- Refuse anchor-less forced retries (R14-4): after a fail-closed trim drops
  both cursor and beforeRecordId, a forced retry fetched with neither anchor,
  and the daemon defaults that to the journal's oldest page — prepended below
  the window and re-stamping a bogus anchor. Return early until a later trim
  re-establishes an anchor.

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

Partially reviewed — gaps disclosed.

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

Deferred under the convergence posture (round 15, not a blocker) — recorded, not requested in this round:

  • packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx:13141 (+3 locations) — [probe] Three new retention tests allocate MB-scale payloads (~90 MB / ~70 MB / 8 MiB Blobs) where sibling tests drive the identical byte-trim and admis…
  • packages/sdk-typescript/src/daemon/ui/transcript.ts:1625 — [probe] maxBlocks: Infinity collapses the count floor to 1 and silently over-evicts the whole window except the last record group (latent; no in-tree producer)
  • packages/webui/src/daemon/session/DaemonSessionProvider.tsx:861 — [review] Fail-closed trim branch skips the rejectedPage footprint growth (R14-3's growth sits in the anchored arm); one bounded fetch/reject churn cycle, self-heals on re-lat…
  • packages/webui/src/daemon/session/DaemonSessionProvider.tsx:3538 — [review] Anchor-less force-retry guard (the R14-4 fix) silently no-ops while the paginationError Retry affordance stays visibly latched
中文说明

仅完成部分审查,审查缺口已披露。

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

收敛姿态下延后(第 15 轮,非阻断)——已记录,本轮不要求修改:共 4 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.21.14)

Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix deferred this item to a human under instruction (round 11/100) — the agent's handoff note below names the decision and the options. The loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own.

What I found before stopping:

Maintainer decision needed: PR #9303 is not converging — round stopped with no code changes

The decision

This PR's diff has stayed over the counting window's growth budget for 7+ rounds
(source 803 / test 1844 net lines vs budgets of 400/400) and is still not
shrinking: each review round produces findings whose fixes grow the diff, and
Critical-only mode cannot help because the Criticals ARE the growth. Per the
convergence rule this round applies no code fixes. The call belongs to a
maintainer: how to close this PR without another growth round.

What was tried

  • 14 rounds of review-driven fixes (the "round-1" through "round-14" commits)
    implementing and hardening bounded daemon transcript retention: block + byte
    budgets, pagination/eviction reconciliation, replay-trim anchor handling, and
    re-open / forced-retry gating in DaemonSessionProvider.tsx plus the SDK
    transcript.ts store. The branch now has 27 commits ahead of main,
    +2974 / -104 lines across 21 files.
  • The window already engaged Critical-only mode after round 5; rounds kept
    producing new Criticals in the same areas anyway.
  • The round-15 CHANGES_REQUESTED review disclosed its own gaps and explicitly
    deferred all non-blocking items "under the convergence posture", requesting
    nothing this round.

What remains open

  1. R15-1 (Critical, rc:3824785092): positive fractional maxBlocks passes
    through the R14-1 degenerate-value clamp (transcript.ts, the
    effectiveMaxBlocks comput
中文说明

🤖 AutoFix 已按指示将此项移交人工处理(第 11/100 轮)—— 下方 agent 的 handoff 说明列出了待决决策与各选项。循环保持在线,仍会拾取新反馈与 base 冲突,但不会自行重试此项。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/32411144143


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

…LM#9303)

Round-15 review fix (R15-1): the R14-1 degenerate-maxBlocks clamp floored
non-positive and non-finite values to 1 but passed positive fractional values
through unchanged, leaving removeCount fractional so the record-boundary snap
indexed blocks[removeCount] one past the end and threw on later dispatches —
the same crash class R14-1 was added to prevent, re-entering through a sibling
input. Floor the clamped value so the window always trims by a whole block
count. Math.floor is a no-op for the integer maxBlocks every in-repo caller
passes. Extends the degenerate-maxBlocks regression test with a fractional case.
@wenshao
wenshao enabled auto-merge August 20, 2026 23:03

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

Re-review at head 1fc998038 — verdict: ✅ Approve

本轮复审范围:我上一次 review 之后的 4 个提交(round-12 cec40b8、round-13 ca93bb0、round-14 0cc8d81、fractional maxBlocks 修复 1fc9980)。以下每一项都在 head tree 上逐段核实,而非只看 diff。

此前 4 项 blocker — 全部真实修复

(1)floor back-off 单 if 会切断 3+ 兄弟 record — 已修复。trimTranscriptState 的回退改为 while 循环(packages/sdk-typescript/src/daemon/ui/transcript.ts:1672-1681):循环必然终止(removeCount 每轮减 1)、索引始终有效、每次 re-retain 的兄弟块字节都正确加回。当无可驱逐块时 removeCount === 0 保留整窗而不是 mid-record 切断(transcript.ts:1685)。与新增的 3-block record 回归测试一致。

(2)live trim 不为 unlatched 会话重开旧历史入口 — 已修复。re-anchor 时,若会话以 hasMore=false、capacityReached=false 状态加载,现在会重开 affordance,门控为 pagination feature 支持 + trim 后字节余量(packages/webui/src/daemon/session/DaemonSessionProvider.tsx:833-853),与 replay 路径的门控完全镜像(DaemonSessionProvider.tsx:2172-2178);分支内 anchor 必然已赋值(:801)。

(3)空文本、仅媒体 echo 绕过去重 — 已修复。userBlockBoundaryKey 按 echo 存在性生成键并把 image/file 数量折入键中(DaemonSessionProvider.tsx:317-330),门槛由"文本非空"改为 !== undefined(DaemonSessionProvider.tsx:355-366)。注:键分隔符是两个字面 NUL 字节,属刻意的防碰撞选择;其"文件对 grep 呈 binary"的副作用已在 round-14 延后清单中记录,此处不再重复提出。

(4)Blob/File 载荷对字节估算器不可见 — 已修复。estimateRetainedBytesBlob(含 File)按 .size 计费、对 ArrayBuffer/typed view 按 .byteLength 计费(transcript.ts:2053-2055),并有 typeof Blob 守卫保护非浏览器宿主。计费与驱逐均有新增测试覆盖。

Round-14 criticals 与 fractional maxBlocks — 全部修复并核实

  • 退化 maxBlocks 夹紧:Math.max(1, Math.floor(Number.isFinite(...) ? ... : 1))(transcript.ts:1623-1626),覆盖 0/负数/小数/NaN;1fc998038 的 floor 使 2.5 按 2 处理。
  • rebuild fail-closed 分支无条件丢弃 anchor(DaemonSessionProvider.tsx:2156),配合 loadMoreTranscript 中"无 anchor 拒绝 force-retry"的守卫(DaemonSessionProvider.tsx:3533-3538),堵住了"默认请求最旧页、重新盖上伪 anchor"的路径。
  • latched rejectedPage footprint 按驱逐带增长(DaemonSessionProvider.tsx:812-826):我验证了它依赖的前提——回调触发时 store.getSnapshot() 仍是 trim 前状态,因为 createDaemonTranscriptStore.dispatch 在 reduce 返回后才交换 state(packages/sdk-typescript/src/daemon/ui/store.ts:64-69)。增长恰好抵消释放的容量(daemon 会在下次 fetch 重新serve被驱逐带),因此 latch 只能经 rewind/重载重开——这正是重写后测试所断言的防 churn 行为,按有意设计看待。

我上一轮的 🟡 I1(hasCapacity 忽略字节预算)— 实质解决

admission 现在是双维度拦截(count:DaemonSessionProvider.tsx:386;bytes::397),capacity latch 的重开门控同样是双维度(:913-916)。成功路径上仅看块数的 hasCapacity(:3697)残余影响至多为:字节饱和而块数有余的窗口下出现一次用户主动的乐观 fetch,随后落入字节感知的 latch;MessageList 侧无自动重试循环,不会 churn。不再阻断;bot ledger 已将此条记录为延后项。

两个记录性观察(非阻断)

💡 Number.isFinite 夹紧会把 Infinity 当作退化输入夹到 1:假想中有调用方传 maxBlocks: Infinity 且字节预算有限时,字节压力下会被 count floor 一次驱逐到 1 块,而非纯字节驱动驱逐。当前无任何调用方传 Infinity(web-shell 全部使用 WEB_SHELL_MAX_TRANSCRIPT_BLOCKS,默认 50k),仅作为 SDK 公开选项的语义提示,不作修改要求。


English

Re-review scope: the four commits since my last review (round-12 cec40b8, round-13 ca93bb0, round-14 0cc8d81, fractional-maxBlocks fix 1fc9980). Every item below was verified against the head tree, not just the diff.

The four previously raised blockers — all genuinely fixed

(1) Floor back-off single-if cut through records with 3+ sibling blocks — fixed. The back-off is now a while loop (packages/sdk-typescript/src/daemon/ui/transcript.ts:1672-1681): it terminates (removeCount decreases each iteration), indexes stay valid, and each re-retained sibling's bytes are added back. When nothing is left to evict, removeCount === 0 keeps the whole window rather than cutting mid-record (transcript.ts:1685). Matches the new 3-block-record regression test.

(2) Live trim didn't re-open older history for unlatched sessions — fixed. On re-anchoring, a session loaded with hasMore=false and capacityReached=false now re-opens the affordance, gated on pagination feature support plus post-trim byte headroom (packages/webui/src/daemon/session/DaemonSessionProvider.tsx:833-853), exactly mirroring the replay-path gate (DaemonSessionProvider.tsx:2172-2178); the anchor is guaranteed in scope (assigned at :801).

(3) Empty-text media-only echo bypassed dedup — fixed. userBlockBoundaryKey keys on echo presence and folds image/file counts into the key (DaemonSessionProvider.tsx:317-330); the gate is !== undefined instead of non-empty text (DaemonSessionProvider.tsx:355-366). Note: the separators are two literal NUL bytes — a deliberate collision-proof choice; the "file looks binary to grep" side effect is already recorded in the round-14 deferred list, so I'm not re-raising it.

(4) Blob/File payloads invisible to the byte estimator — fixed. estimateRetainedBytes charges Blob (hence File) by .size and ArrayBuffer/typed views by .byteLength (transcript.ts:2053-2055), guarded by typeof Blob for non-browser hosts. Both charging and eviction are covered by new tests.

Round-14 criticals and fractional maxBlocks — fixed and verified

  • Degenerate maxBlocks clamp: Math.max(1, Math.floor(Number.isFinite(...) ? ... : 1)) (transcript.ts:1623-1626) covers 0/negative/fractional/NaN; the floor in 1fc9980 makes 2.5 behave as 2.
  • The rebuild fail-closed branch drops the anchor unconditionally (DaemonSessionProvider.tsx:2156), and together with the anchor-less force-retry refusal in loadMoreTranscript (DaemonSessionProvider.tsx:3533-3538) this closes the "default oldest-page request re-stamps a bogus anchor" path.
  • Latched rejectedPage footprint growth by the evicted band (DaemonSessionProvider.tsx:812-826): I verified the premise it relies on — store.getSnapshot() at callback time is still the pre-trim state because createDaemonTranscriptStore.dispatch swaps state only after the reduce returns (packages/sdk-typescript/src/daemon/ui/store.ts:64-69). The growth cancels the freed capacity by design (the daemon re-serves the evicted band on the next fetch), so the latch re-opens only via rewind/reload — exactly the anti-churn behavior the rewritten test asserts; I treat it as intentional.

My previous 🟡 I1 (hasCapacity ignored the byte budget) — effectively resolved

Admission is now dual-dimension (count at DaemonSessionProvider.tsx:386, bytes at :397), and the capacity-latch re-open gate is dual-dimension too (:913-916). The remaining count-only hasCapacity on the success path (:3697) can at worst surface one user-initiated optimistic fetch when the window is byte-saturated with count headroom; it then lands on the byte-aware latch, and the MessageList side has no auto-retry loop. No longer blocking; the bot ledger already tracks this as a deferred item.

Two recorded observations (non-blocking)

💡 The Number.isFinite clamp treats Infinity as degenerate and clamps it to 1: a hypothetical consumer passing maxBlocks: Infinity with a finite byte budget would, under byte pressure, be evicted down to a single block by the count floor instead of pure byte-driven eviction. No current caller passes Infinity (web-shell uses WEB_SHELL_MAX_TRANSCRIPT_BLOCKS throughout; default 50k), so this is a semantics note on the SDK's public option, not a requested change.

@wenshao

wenshao commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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: 128 passed · 21 failed · 149 total

Flakiness gate: ✅ 4 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:128 通过 · 21 失败 · 149 总计

抖动门:✅ 4 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #9303 Deep Verification (follow-up round) — fix(web-shell): bound daemon transcript retention to stop renderer OOM crashes

Verdict: findings — 149 scripted assertions executed: 128 pass / 21 fail. Verified head: 1fc9980382f090247586c1bac6d9e3c26a4ece3d (merge commit adb2bf5268, base tip HEAD^1 = 5715782279938080ee664004db4b93ae19231384). Follow-up round: the previous report (verified head 8447f297e8) is carried forward; every measurement below was rebuilt and re-executed at the new head against the new base — nothing was diffed from the old report. The central claim is proven load-bearing again (A/B 54/54 head, 39/39 base controls as predicted), all three workspace suites are green (sdk 1638 / webui 595 / web-shell 3998), and the delta's new trim-floor guards were probed directly. The 21 fails are: F1 byte-accounting drift (13 step invariants), F2 toolPreview named rows (4), F3's three surviving mutants (3), and one NEW finding — the new R15-1 test's fractional subcase is vacuous (1).

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)
  • 结论 findings:149 条脚本断言,128 通过 / 21 失败。核心结论(transcript 保留真正受限)在新 head(1fc9980382)/新 base(5715782279)上再次经 A/B 证实:head 侧 54/54 成立、base 对照 39/39 按预期(见 01-ab-retention-head-vs-base.png)。三个 workspace 套件全绿(sdk 1638 / webui 595 / web-shell 3998)。
  • 本轮增量:自上轮 head 8447f297 以来新增 3 个提交(round-13、round-14、R15-1 trim floor 取整)。浅克隆(depth 2)下仅 1 个提交可达(快照含 28 个),无法逐 commit 归因,已验证聚合 diff。新守卫实测:R14-1 的 Math.max(1,…) 下限真实生效且被其测试钉住(变异体 M10 被杀);R15-1 的 Math.floor 真实生效(harness D2/D3 单元),但其测试的小数分支空转——变异体 M9 去掉 floor 后套件仍 324/324 全绿,而同一变异体在到达 trim 的真实 fixture 下会崩溃(见 05-r15-vacuity-proof.png)。
  • 上一轮发现状态:F1(retainedBytes 记账遗漏变更路径)仍然存在——重测 13/17 步不变量失败,累计漂移 −80,500B(见 02-f1-byte-accounting-drift.png),且反向变异体 M11 证明套件对该轴无任何钉住;F2(toolPreview 命名行未截断)仍然存在,4/4 行保留 100,000 字符(03-f2-toolpreview-uncapped-rows.png);F3(R12-1/R12-2/R12-10 无测试钉住)仍然存在,M5/M6/M7 在 237 测试全量文件下同样存活(04-mutation-matrix-6-killed-4-survived.png)。
  • 未覆盖:真实浏览器 renderer 内存浸泡;provider 层角例仅经套件/变异验证;detachString 父串释放未用堆快照证明;逐 commit 归因;离线投影(chat-record-transcript)仅经套件覆盖。

Previous-finding status (follow-up round)

Delta since the previous round: three commits after 8447f297ca93bb01 (round-13 critics), 0cc8d817 (round-14 critics), 1fc9980382 (R15-1 integral-ize the trim floor). The checkout is depth-2 shallow: 1 commit reachable locally vs 28 in the metadata snapshot, so per-commit deltas are unreachable and the aggregate HEAD^1..HEAD diff was verified. The carry-forward shortcut was not used (input closure changed: new head, new base), so everything was re-executed.

# Finding (previous report, head 8447f29) Severity Status at new head 1fc9980
F1 retainedBytes accounting misses block-mutation paths Suggestion Stands — re-measured. 13/17 step invariants fail; drift sites this round: applyAssistantUsage −192B, upsertPermissionBlock existing-branch shrink not deducted (+52B un-booked), resolvePermissionBlock −62B, upsertPermissionBlock existing-branch 20KB toolCall replace −79,998B, applySubagentUsageToParentTool −300B; cumulative −80,500B. New reverse-mutant M11 (correct accounting ADDED) leaves the suite green — the axis is unpinned in both directions. Witness 02-f1-byte-accounting-drift.png.
F2 toolPreview named-candidate rows (Path/Cwd/Query/Note) bypass capDetails Suggestion Stands — re-measured. All four named rows retain 100,000 chars at head (4/4); generic control capped at 4111; base identical (pre-existing gap, not a regression). Witness 03-f2-toolpreview-uncapped-rows.png.
F3 R12-1 / R12-2 / R12-10 fixes ship unpinned Suggestion Stands — re-measured. Same 3 survivors (M5/M6/M7) under full-file provider runs (237/237 green on each mutant); killed siblings M3/M4 in the same file prove the command collects the coverage. Rounds 13/14 added 5 provider tests (232 → 237) but none pin these guards. Witness 04-mutation-matrix-6-killed-4-survived.png.
M1–M4, M8 Matrix kills (byte budget, snapshot release, rebuild cap, provider consume call, floor back-off) Superseded by this round's matrix — re-run at the new head, same kills, same attributed tests.
Gates sdk 1631 / webui 565 / web-shell 3850 Re-run: sdk 1638 / webui 595 / web-shell 3998 (deltas include this PR's new round-13/14/15 tests; all green). Witness 06-suite-gates-live.png.

Central claim and A/B

Central claim: the web shell's retention of daemon session history is bounded — replay rebuilds run under the configured block cap and can never ratchet it upward, oversized replays are trimmed to the most recent blocks, the replay snapshot is released after injection, diagnostic payloads are capped at the producer, retention is byte-budgeted with record-boundary-aware eviction and floor back-off, and the trim floor tolerates degenerate/fractional maxBlocks (this round's delta).

Harness ab-retention.mjs imports the compiled dist/daemon/index.js of head (rebuilt from the verified merge tree) and of base (tmp/base-tree at HEAD^1 = 5715782279, sdk-typescript rebuilt there against the root node_modules; lockfile untouched by the PR). Control purity: both arms' daemon bundles have zero non-relative imports (import-scan quoted in Methodology), so neither cell can resolve into the other tree. Witness: 01-ab-retention-head-vs-base.png.

# Cell (oracle) base (5715782) head (1fc9980)
C1 Diagnostic cap — 4 producers × 100KB payload 4/4 UNcapped (≥90KB embedded; sidechannel/block routing identical) 4/4 capped at exactly 4111
C2 Cap ladder around 4096 (pre-cap 3000/4090/4096/4097/5000) uncapped at every rung ≤4096 byte-identical / >4096 → exactly 4111
C3 Astral payload straddling the 100k text-block cut capped (pre-existing) but raw slice leaves a lone surrogate capped, no lone surrogate (detachString UTF-8 round-trip → U+FFFD)
C4 Replay rebuild 300 blocks, cap 100, +50 live (provider lines emulated verbatim per arm) 300 retained; committed cap ratcheted to 300; live window trims at the ratcheted 300 trimmed to 100; committed cap 100; live growth trims at 100 with evictedOldest truncation detail
C5 Byte budget — 100 × ~120KB blocks, budget 2MB no mechanism (maxRetainedBytes undefined); all 100 retained evicted below 100; retained ≤ budget + worst block; accounting exact on all 100 pure-append steps
C6 Record-boundary snap — 5-block record-A + 5-block record-B + trigger, cap 8 naive cut straddles record-A (keeps a4/a5 of 5) cut advanced past the record; window = 5 × record-b + trigger, no partial record
C7 Floor back-off — ~100KB blocks sharing a record, budget 150KB no byte mechanism; all kept trivially shared-record pair re-retained (floor backed off); distinct-record control evicts the unshared head
C8 Snapshot release (consumeReplaySnapshot) method absent; snapshot pinned for client lifetime returns snapshot once, swaps empty, idempotent
C9 toolPreview generic row (100KB scalar) UNcapped (≥99KB) capped at 4111
S1 Store seeding counts retained bytes retainedBytes undefined seeded retainedBytes = Σ estimates (368 = 368)
D1 delta — maxBlocks=0 (R14-1) n/a (base slice(-0) keeps all, no crash — hazard is PR-introduced) no throw; ≥1 block kept
D2 delta — maxBlocks=2.5, 5 distinct blocks (R15-1) n/a (base slice truncates fractionals harmlessly) no throw; keeps exactly floor(2.5)=2 newest blocks
D2b delta — the PR's own R15-1 fixture (5× user.text.delta) census: 1 merged block → trim never reached (see F4)
D3 delta — degenerate ladder NaN / −5 / Infinity no throw; NaN→1 kept, −5→1 kept, Infinity→all 5 kept (early-return guard)

Result: 54/54 head, 39/39 base — every head behavior holds and every base control behaves exactly as predicted. C4 emulates each arm's provider lines verbatim (base: replayMaxBlocks = Number.MAX_SAFE_INTEGER + committedMaxBlocks = Math.max(maxBlocks, replayState.blocks.length), base provider L1683-1722; head: replayMaxBlocks = maxBlocks + committedMaxBlocks = replayMaxBlocks, head provider L2013-2090).

Delta assessment (R14-1 / R15-1): both guards are real and load-bearing — mutant M10 (clamp removed) throws TypeError: Cannot read properties of undefined (reading 'sourceRecordIds') in the intended test, and mutant M9 (floor removed) crashes the harness's D2 cell with the same error while 5-block windows trim to exactly 2. Attribution note: the degenerate/fractional hazard is specific to the PR's snap-index arithmetic; base's naive slice(-maxBlocks) coerces fractionals without crashing (D-base cells), so R14-1/R15-1 defend a mechanism this PR introduced. The one defect found in the delta is the R15-1 test fixture (F4), not the fix.

Bundle budget: dist/daemon/index.js = 206,355 B ≤ 210,944 B (206KB budget); every rebuild exercised assertBrowserSafeBundle inside scripts/build.js and passed.

Findings

F1 (Suggestion, carried forward — stands) — retainedBytes accounting still misses block-mutation paths

Re-measured at the new head with byte-accounting.mjs (17 step invariants across append/growth/tool/permission/subagent/attachment/trim paths; asserts retainedBytes === Σ estimate(blocks) after every step). 13/17 step invariants fail; the invariant breaks at S04 and never recovers (cumulative drift −80,500B). Witness 02-f1-byte-accounting-drift.png.

Step Path Unaccounted delta
S04 applyAssistantUsage (usage object on active assistant) −192B
S08 upsertPermissionBlock existing-branch (small replace shrinks block) +52B never deducted
S09 resolvePermissionBlock (resolved/eventId on existing block) −62B
S10 upsertPermissionBlock existing-branch (20KB toolCall replace) −79,998B
S12 applySubagentUsageToParentTool (executionSummary on parent tool) −300B

Direction is under-count (one missing deduction), so the effective ceiling is budget + worst-case block + accumulated unaccounted deltas — bounded, no correctness hazard, but contradicts the PR's exactness invariant. New this round: reverse-mutant M11 added correct measure-before/delta-after accounting to applyAssistantUsage — the suite stayed green (324/324) while the harness's S04 flipped FAIL→PASS (drift 0), then restored (FAIL again). The suite pins nothing along these axes in either direction. Repro: node tmp/pr9303-verify-20260821-024938/byte-accounting.mjs. The previously validated scratch fix was again not applied by the PR.

F2 (Suggestion, carried forward — stands) — toolPreview named-candidate rows still bypass capDetails

Re-measured: createDaemonToolPreview({ path|cwd|query|description: 'x'.repeat(100_000) }) retains 100,000 chars in the Path/Cwd/Query/Note rows on head (4/4), while the generic path caps at 4111 (control). Base is identical (uncapped everywhere) — residual gap in the PR's own hardening, not a regression; retention impact bounded because the row holds the same string reference the retained rawInput already carries. Witness 03-f2-toolpreview-uncapped-rows.png. One-line fix unchanged: route collectPreviewRows' named-candidate push through capDetails too.

F3 (Suggestion, carried forward — stands) — R12-1 / R12-2 / R12-10 still unpinned by any test

Mutation matrix at the new head (witness 04-mutation-matrix-6-killed-4-survived.png; positive controls: unmutated targeted files green — daemonUi 324/324, provider 237/237 — and six siblings killed by the same commands):

Mutant Single-point change Result Evidence
M1 overByteBudget = false KILLED sdk: evicts oldest blocks to stay under the retention byte budget + 2 more (3 failed)
M2 consumeReplaySnapshot keeps the snapshot KILLED sdk: releases the replay snapshot once consumed (1/69 failed)
M3 rebuild cap → Number.MAX_SAFE_INTEGER KILLED provider: 6 tests incl. trims an oversized initial replay to the block cap and re-anchors older pagination, uses a bounded full-snapshot fallback after the marker block is trimmed
M4 provider consumeReplaySnapshot() call removed KILLED provider: releases the replay snapshot after injection and never raises the block cap
M5 (R12-1) boundary-dedup break removed → window-wide text keying SURVIVED provider 237/237 green
M6 (R12-2) rewind gate dropped from rebuild observeReplayTrim SURVIVED provider 237/237 green
M7 (R12-10) loadMore catch generation guard → if (false) SURVIVED provider 237/237 green
M8 (R12-21) floor back-off disabled KILLED sdk: both R12-21 back-off tests (2 failed)
M9 (R15-1) Math.floor removed from trim floor SURVIVED the suite — but see F4 sdk 324/324 green; harness D2 crashes
M10 (R14-1) Math.max(1, …) clamp removed KILLED sdk: the new tolerates a degenerate maxBlocks… (R14-1) test, failing at its .not.toThrow() with the exact TypeError the clamp prevents
M11 F1 reverse: correct accounting ADDED green (expected) proves F1 axis unpinned; harness S04 flips PASS↔FAIL with it

M5/M6/M7 classify as coverage gaps, not defects — the fixes read correctly and the killed siblings prove the pattern testable. Pinning fixtures unchanged from the previous round.

F4 (Suggestion, NEW this round) — the R15-1 fractional subcase of the new test never reaches the trim floor

The new test tolerates a degenerate maxBlocks without crashing the trim (R14-1) adds an R15-1 subcase: a store with maxBlocks: 2.5 receiving five user.text.delta dispatches, asserted not to throw. Census (D2b): five consecutive user.text.delta events merge into one block via the active-user-block path, and 1 <= 2.5 takes the trim's early return — the fractional removeCount path is never executed. Proof chain (05-r15-vacuity-proof.png):

  1. Mutant M9 (remove Math.floor, keep the clamp) → daemonUi.test.ts stays 324/324 green.
  2. Same mutant, rebuilt dist, harness D2 (five distinct tool blocks at cap 2.5, which does reach trim) → crashes: TypeError: Cannot read properties of undefined (reading 'sourceRecordIds') — the floor is load-bearing.
  3. Restored head: suite green again and harness back to 54/54.

The fix itself is correct and verified (D2/D3 at head); only the test's fixture under-covers it. Suggested change (measured, not eyeballed): in the fractional subcase, create ≥3 blocks that cannot merge — e.g. five tool.update events with distinct toolCallIds, exactly the harness D2 fixture. With that fixture the mutant is caught (it crashes at dispatch 3), and head stays green (D2: keeps exactly 2 blocks). No production hazard: this is a test-strength gap on a guard that otherwise holds.

Not covered

  • Real browser renderer memory / long soak — no browser in this container; the A/B proves retention mechanics at the store/client level, not Chrome RSS. The crash scenario is reproduced in shape (oversized replay + live growth through the real reducer/store), not in cause. Author also states the long-soak before/after was not rerun.
  • Provider-level reconciliation corners verified only via the PR's suite, mutation, and the round's mutants — the reconciliation functions are module-private; F3 quantifies what that means for the unpinned guards.
  • detachString parent-release — output equality and absence of lone surrogates verified at the cut (C3); no heap snapshot proving V8 releases the oversized parent string.
  • Per-commit attribution — depth-2 shallow checkout: 1 commit reachable vs 28 in the snapshot (verified mismatch, not a bare count). Only the aggregate HEAD^1..HEAD diff was verified; the round-13/14/15 commits cannot be separated, so their individual claims are covered only in aggregate (R14-1/R15-1 behavior was probed directly).
  • Offline/export projection (projectChatRecordsToDaemonTranscript byte-budget opt-out via maxRetainedBytes: POSITIVE_INFINITY) — covered by the sdk suite only (not exported from the daemon barrel the harness imports); no independent harness.
  • Transient parse peak while downloading a large replay response and daemon-side adaptive journal growth — untouched by the PR, not measured.
  • Snapshot metadata baseRefOid (02d303f8…) has drifted past the local base tip; per the CI merge-ref contract the A/B uses HEAD^1 (5715782279).
  • The flakiness gate (changed test files × 5 rounds) is run by the workflow lane, not by this agent.

Methodology

Environment: CI verify container (node:22-bookworm, Node v22.23.2), merge-ref checkout at depth 2 (HEAD = merge adb2bf5268, HEAD^1 = base 5715782279, HEAD^2 = head 1fc9980382); npm ci + npm run build pre-run at head. Base side: scratch worktree at HEAD^1, only packages/sdk-typescript rebuilt via its own scripts/build.js with the root node_modules/.bin on PATH (lockfile untouched by the PR — clean control). Control purity: recursive import scan of both arms' dist/ shows the daemon bundle has zero non-relative imports on both sides, so neither arm can resolve into the other tree (@qwen-code/* workspace symlinks resolve into the head tree, but nothing in either daemon bundle imports them). The head daemon dist was rebuilt twice during mutant cycles (M9, M11) and restored to 54/54 after each. Harnesses (ab-retention.mjs, byte-accounting.mjs, f2-toolpreview.mjs) drive the compiled dist directly — real normalizer, real reducer/store, real DaemonSessionClient prototype — with per-arm expected outcomes encoded so predicted base behavior counts as passed control assertions; two intermediate fixture errors found during bring-up (C3 surrogate placement, C7 trigger record) were corrected before the final tallies. Mutants M1–M11 were single-point edits applied by mutate.mjs/mutate-m11.mjs (anchor-uniqueness enforced), run against the targeted vitest file, and restored via git checkout -- (tree verified clean after each; git status --porcelain empty at the end). Workspace suites run via npx vitest run in each package, once in background and once live inside capture 06 (identical counts). Evidence images rendered by scripts/verify-capture.mjs from live runs. Raw logs in logs/.

Flakiness gate log

rounds=5 files=4 skipped=0
file packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: (cd packages/sdk-typescript) npx --no-install vitest run ./test/unit/DaemonSessionClient.test.ts
file packages/sdk-typescript/test/unit/daemonUi.test.ts: (cd packages/sdk-typescript) npx --no-install vitest run ./test/unit/daemonUi.test.ts
file packages/web-shell/client/constants/sessions.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/constants/sessions.test.ts
file packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: (cd packages/webui) npx --no-install vitest run ./src/daemon/session/DaemonSessionProvider.test.tsx


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: PPPPP
  packages/sdk-typescript/test/unit/daemonUi.test.ts: PPPPP
  packages/web-shell/client/constants/sessions.test.ts: PPPPP
  packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: PPPPP

verdict: pass
summary: 4 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: P (exit 0)
round 1 · packages/sdk-typescript/test/unit/daemonUi.test.ts: P (exit 0)
round 1 · packages/web-shell/client/constants/sessions.test.ts: P (exit 0)
round 1 · packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 2 · packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: P (exit 0)
round 2 · packages/sdk-typescript/test/unit/daemonUi.test.ts: P (exit 0)
round 2 · packages/web-shell/client/constants/sessions.test.ts: P (exit 0)
round 2 · packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 3 · packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: P (exit 0)
round 3 · packages/sdk-typescript/test/unit/daemonUi.test.ts: P (exit 0)
round 3 · packages/web-shell/client/constants/sessions.test.ts: P (exit 0)
round 3 · packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 4 · packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: P (exit 0)
round 4 · packages/sdk-typescript/test/unit/daemonUi.test.ts: P (exit 0)
round 4 · packages/web-shell/client/constants/sessions.test.ts: P (exit 0)
round 4 · packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 5 · packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts: P (exit 0)
round 5 · packages/sdk-typescript/test/unit/daemonUi.test.ts: P (exit 0)
round 5 · packages/web-shell/client/constants/sessions.test.ts: P (exit 0)
round 5 · packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)

Evidence images

01-ab-retention-head-vs-base

02-f1-byte-accounting-drift

03-f2-toolpreview-uncapped-rows

04-mutation-matrix-6-killed-4-survived

05-r15-vacuity-proof

06-suite-gates-live

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

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

@wenshao
wenshao added this pull request to the merge queue Aug 21, 2026
Merged via the queue into QwenLM:main with commit 54a3a7f Aug 21, 2026
85 of 86 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.22.0.

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

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants