Skip to content

fix(web-shell): Harden prompt admission ownership - #8955

Merged
doudouOUC merged 2 commits into
QwenLM:mainfrom
doudouOUC:fix/8923-admission-owner-guards
Aug 12, 2026
Merged

fix(web-shell): Harden prompt admission ownership#8955
doudouOUC merged 2 commits into
QwenLM:mainfrom
doudouOUC:fix/8923-admission-owner-guards

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

This PR hardens WebShell prompt ownership across session navigation. Direct and queued submissions now revalidate the App lifetime, logical session owner, composer source, and write-gate generation after asynchronous host admission and lazy session preparation, before clearing follow-ups, committing the composer, sending, or enqueueing.

Cancelled failed-prompt and turn-error retries are retained for their logical source session and restored only after navigation settles. Restoration uses persisted user-record identities or stable prompt/event identities instead of reducer-local block IDs, supports same-owner workspace enrichment and stable transcript replay, and fails closed when an attachment or transcript belongs to a different owner. The prompt-safe navigation design contract is updated to document these guarantees.

Why it's needed

#8882 preserves the source session while a target restore is prepared, but asynchronous WebShell continuations could still outlive a navigation transition. A host admission hook or shared lazy-session preparation could return after A→B or A→B→A and commit a stale draft, clear follow-up state, enqueue work, or lose the source retry. Transcript reducers also reuse local block IDs, so using those IDs across attachment replacement could expose a retry for the wrong payload.

The new checks keep stale continuations side-effect free, preserve source-owned draft and retry state, and ensure a retry is offered only when the current transcript has a stable identity match.

Reviewer Test Plan

How to verify

Use a delayed onSubmitBefore hook in session A, begin a direct or queued submission, navigate A→B→A while the hook or lazy session preparation is pending, and then release it. Confirm no stale send or enqueue occurs, the composer and follow-up remain intact, and a valid source retry becomes available only after A is current and navigation has settled.

Repeat with a failed-prompt retry and a turn-error retry while replacing the session attachment. Confirm a stable persisted replay retains exactly one retry, while a new transcript that only reuses the same reducer-local ID exposes no retry and cannot resend the old payload. With a hanging real daemon turn, navigate A→B→A and confirm A remains active and the request ledger contains no navigation-generated cancel, prompt replay, continuation, or mid-turn mutation.

Evidence (Before & After)

Before: focused regressions reproduced a direct send and queued enqueue after the navigation gate changed, and mutation tests reproduced hidden valid retries or stale retry UI after attachment/source-version changes.

After: the full WebShell App suite passes 422/422; the real-daemon session-switching suite passes 5/5 on the latest main; targeted mutation checks fail when the owner, generation, or transcript-identity guards are removed and pass with this change.

Tested on

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

Environment (optional)

macOS, Node.js 22.22.3, npm 10.9.8, local WebShell Vitest, and the bundled real-daemon integration harness with QWEN_SANDBOX=false.

Risk & Scope

  • Main risk or tradeoff: Prompt and retry lifecycle bookkeeping in the WebShell App is more explicit; focused owner/identity mutation tests, the full App suite, and real-daemon navigation tests cover the affected paths.
  • Not validated / out of scope: Windows and Linux were not tested locally. DataWorks host-controller behavior, daemon prompt/cancel semantics, public REST/ACP/SDK interfaces, branch/fork navigation, and legacy source visibility remain out of scope.
  • Breaking changes / migration notes: None. No public protocol, SDK type, header, or persisted-data migration is introduced.

Linked Issues

Refs #8923

中文说明

本 PR 做了什么

本 PR 加固了 WebShell 在会话导航期间的 prompt 所有权。直接提交和排队提交在异步宿主准入及懒会话准备完成后、清除 follow-up、提交 composer、发送或入队之前,会重新校验 App 生命周期、逻辑会话 owner、composer source 和 write-gate generation。

被取消的 failed-prompt 与 turn-error retry 会保留在其逻辑源会话中,并且只在导航结束后恢复。恢复逻辑使用持久化 user record identity 或稳定的 prompt/event identity,而不是 reducer 局部 block ID;它支持同 owner 的 workspace 补全和稳定 transcript replay,并在 attachment 或 transcript 属于不同 owner 时 fail closed。Prompt-safe 导航设计契约也同步记录了这些保证。

为什么需要

#8882 在目标 restore 准备期间保留源会话,但异步 WebShell continuation 仍可能晚于导航 transition 返回。宿主准入 hook 或共享懒会话准备可能在 A→B 或 A→B→A 之后继续提交旧 draft、清除 follow-up 状态、将任务入队,或者丢失源会话 retry。Transcript reducer 还会复用局部 block ID,因此跨 attachment replacement 使用这些 ID 可能为错误 payload 暴露 retry。

新的校验让过期 continuation 不产生副作用,保留源会话拥有的 draft 和 retry 状态,并且只在当前 transcript 具备稳定 identity 匹配时提供 retry。

Reviewer 测试计划

如何验证

在会话 A 中使用延迟的 onSubmitBefore hook,开始一次直接提交或排队提交,在 hook 或懒会话准备仍 pending 时执行 A→B→A,然后释放它。确认不会发生过期 send 或 enqueue,composer 和 follow-up 保持不变,并且有效的源会话 retry 只会在 A 成为当前会话且导航结束后恢复。

分别对 failed-prompt retry 和 turn-error retry 重复 attachment replacement。确认具有稳定持久 identity 的 replay 只保留一个 retry,而仅复用相同 reducer 局部 ID 的新 transcript 不显示 retry,也不能重发旧 payload。使用挂起真实 daemon turn 时执行 A→B→A,确认 A 仍保持 active,并且请求账本中没有导航生成的 cancel、prompt replay、continuation 或 mid-turn mutation。

证据(修改前与修改后)

修改前:聚焦回归测试复现了导航 gate 变化后的直接 send 和 queued enqueue;mutation 测试也复现了 attachment/source-version 变化后有效 retry 被隐藏或过期 retry UI 被保留的问题。

修改后:完整 WebShell App 测试 422/422 通过;基于最新 main 的真实 daemon 会话切换测试 5/5 通过;移除 owner、generation 或 transcript-identity guard 时,定向 mutation 测试会失败,保留本次修复时则通过。

已测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS、Node.js 22.22.3、npm 10.9.8、本地 WebShell Vitest,以及设置 QWEN_SANDBOX=false 的 bundle 真实 daemon 集成测试环境。

风险与范围

  • 主要风险或取舍:WebShell App 中的 prompt 与 retry 生命周期记账更显式;定向 owner/identity mutation 测试、完整 App 测试和真实 daemon 导航测试覆盖了受影响路径。
  • 未验证 / 范围外:未在本地测试 Windows 和 Linux。DataWorks 宿主控制器行为、daemon prompt/cancel 语义、公开 REST/ACP/SDK 接口、branch/fork 导航以及 legacy 源会话可见性不在本次范围。
  • 破坏性变更 / 迁移说明:无。不新增公开协议、SDK 类型、header 或持久化数据迁移。

关联 Issue

Refs #8923

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

Copy link
Copy Markdown
Collaborator Author

E2E test report

Validated after rebasing onto origin/main at 962dc8eadc, including #8933.

  • packages/web-shell/client/App.test.tsx: 422/422 passed with the localStorage test shim.
  • Focused retry/navigation owner group: 22/22 passed. The final sidebar source-version regression was mutation-tested: removing the restored owner refresh makes the same formal test fail because the source retry remains hidden.
  • npm run build && npm run bundle && npm run typecheck: passed.
  • Focused ESLint for the changed WebShell files and git diff --check: passed.
  • QWEN_SANDBOX=false npx vitest run cli/qwen-serve-webui-session-switching.test.ts --reporter=dot: 5/5 passed against the rebuilt bundle.

The real-daemon cases cover an active A turn across A→B→A, a structured target 504 that preserves A and its waiter, transactional source preservation, and serialization of non-equivalent load/resume requests. The request ledger asserts exactly one source /prompt and no navigation-generated /cancel, extra /prompt, /continue, or /mid-turn-message for the active-source flow.

Existing React act(...) and jsdom warnings remain in the suites; no assertion or command failed.

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 11, 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 11, 2026

Copy link
Copy Markdown
Collaborator

Re-run at the fix commit 0ab4f16 — gate re-check.

Template still looks good ✓

Problem: unchanged from the first pass — #8923 is an observed bug with a real request sequence captured in an embedded deployment (POST /session/A/cancel while A had an active turn, and a duplicate POST /session/A/prompt after A→B→A). The browser-side call site was never identified, so the fix stays validated by construction (guards, pinned regression tests, and the sandboxed A/B run on the earlier head), not by reproducing the exact production incident.

Direction: aligned, unchanged — session-navigation integrity is the linked issue's roadmap item; no public protocol, SDK, or daemon changes.

Size: core module paths are not touched (single package). Production logic is now 1,146 lines in App.tsx (+986/−160) — just past the 1,000-line advisory mark — plus 8,759 test lines and a two-line design-contract addition. Non-blocking, but with the file at ~12.6k lines a follow-up extracting the prompt-admission/retry lifecycle into its own hook is worth scheduling.

Approach: the fix commit is tightly scoped — it addresses exactly the five findings from the last review round (re-arm instead of wipe/remap, background-notification exclusion, owner-match early return in the reset effect, catch-side restore, owner-scoped local-id fallback) and adds seven tests pinning each restored affordance. No drive-by changes. The remaining S-level suggestions are deferred in-thread with reasons, per the five-round rule.

Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths.

Moving on to code review. 🔍

中文说明

在修复提交 0ab4f16 上重跑 —— 门禁复查。

模板依然完整 ✓

问题:与第一轮结论一致 —— #8923 是已观测到的 bug,嵌入式部署中捕获到真实请求序列(会话 A 活跃 turn 期间的 POST /session/A/cancel,以及 A→B→A 后重放的重复 POST /session/A/prompt)。浏览器端调用方始终未被定位,因此该修复仍是构造性验证(guard、钉住式回归测试、前一 head 上的沙箱 A/B 运行),而非复现生产事故本身。

方向:对齐,未变 —— 会话导航完整性是关联 issue 的 roadmap 项;未改动公开协议、SDK 或 daemon。

规模:未触及核心模块路径(单一 package)。生产代码现为 App.tsx 中 1,146 行(+986/−160),刚越过 1,000 行提示线,另有 8,759 行测试与两行设计契约补充。不构成阻塞,但文件已约 12.6k 行,后续值得安排一次把准入/retry 生命周期抽成独立 hook 的跟进。

方案:修复提交范围收敛 —— 恰好处理上一轮 review 的五个发现(以重新武装替代 wipe/remap、排除后台通知、reset effect 中 owner 匹配时提前返回、catch 侧恢复、owner 作用域的本地 id 兜底),并为每个恢复的入口新增钉住测试,共七个。无夹带改动。其余 S 级建议已按五轮规则在 thread 中逐条记录延期原因。

风险:无升级风险信号 —— 改动文件均未命中与 revert 相关的高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at 0ab4f1623706dce2e3915287b78322d70a9c4fee · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Verification of the fix round at 0ab4f16

The author pushed one focused commit answering the previous round's five confirmed findings. I re-verified each fix against the code at the reviewed head, traced the state machines it touches, and read the new tests. All five hold.

1. Turn-error retry that fails again (was: permanently suppressed). The catch of handleRetry now stashes the retried payload (failedTurnErrorRetryRef) when the failure is a daemon turn error, and a new rearmFailedTurnErrorRetry re-offers the retry once a distinct turn_error block lands: it checks the owner still matches, requires the original error block to still exist in the transcript (a replaced transcript clears the stash — fail-closed), restores the payload refs, points the identity at the new error, and nulls retriedTurnErrorIdRef so the remap branch no longer suppresses it. I traced both promptId cases through the effect ordering: fresh promptId converges via the identity-wipe path being skipped (the re-arm rebinds identity first), reused promptId converges over two effect passes once the settled-retry clearing effect drops failedPromptRetry. The pre-existing remap still handles the legitimate stable-replay case. Pinned by it.each covering a fresh and a reused prompt id — asserting the hint disappears, reappears on the new error, and resends the original text with retry: true.
Related honesty fix: the suite's isDaemonTurnError mock was () => false before, so the old tests could never exercise this catch path at all — that is how the regression slipped through round one. The mock now detects a real marker.

2. Background notifications killing the retry affordances. getLatestUserBlock, findUserMessageByIdentity, and getRetryableTurnError all skip meta.source === 'background_notification' user-kind blocks now, so render gating and the reconcile effect agree again. Pinned by one test per retry type, asserting the affordance survives a notification landing after the failure.

3. First prompt of a new session losing retryability. The retry-owner useLayoutEffect early-returns when the previous owner already matches the current connection (session id, workspace, source version) — exactly the shape suggested last round — and the reset path also clears the new stash. Crucially, the previously-passing-for-the-wrong-reason test was fixed: the connection commit now happens inside act() after the submit continuation has written the refs, so the layout effect genuinely fires in real React ordering and would go red if the early return were removed.

4. handleRetry catch missing the restore. A !admitted failure now goes through restoreOrDeferCancelledRetry with the same fields handleFailedPromptRetry uses, giving both retry paths parity. Pinned by a network-rejection test asserting the resend happened and the retry action returns.

5. Optimistic blocks under copy-on-write. The design call landed as suggested: matchesUserMessageIdentity gained an allowLocalId fallback that callers enable only while the retry owner's captured snapshot is current — so a same-owner clone keeps matching by local id, while cross-owner/cross-attachment id reuse stays fail-closed (an unconditional fallback would reintroduce the id-collision bug this PR exists to fix). Applied consistently at all five gates (reconcile, admission validation, transcript-identity check, render gate, visible-block gate), plus a rebind branch that re-anchors the failed-prompt identity onto a replayed stable record. Pinned by a clone test and a replay-onto-sourceRecordIds test.

Bonus robustness: cancelled-retry entries now carry a monotonic attemptId and merge via mergeCancelledRetryEntries, so out-of-order admission settlements keep the newer retry instead of clobbering it — pinned by a dedicated test, and the design contract gained the matching sentence. The author's inline replies map 1:1 to these fixes; the deferred S-level items are recorded per the five-round rule.

Non-blocking notes for the maintainer:

  • CancelledRetryState.errorId is still write-only (the deferred R1-8 cleanup) — harmless, noted for the follow-up.
  • applyCancelledRetryState matches without the local-id fallback; in practice a fenced-away failed block cannot be cloned while the user is in another session, so this is theoretical — no action needed.
  • The new-head sandboxed /verify run will re-prove the navigation A/B and mutation matrix on top of these fixes; see below.

CI test evidence

CI on the reviewed head settled green — every substantive check passed, zero failures:

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

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

Integration Tests (CLI, No Sandbox) and the macOS/Windows test legs are skipped for fork PRs as usual — not a gap introduced by this PR.

Sandboxed verification: the first @qwen-code /verify run already proved the load-bearing navigation claim on the earlier head — the identical test file flipped 32 red on the base build vs green on the PR build, and all six single-guard mutants were killed with no off-target effects. A second /verify is in flight for this head (run); it will re-run the A/B and mutation matrix over the re-arm machinery added in this commit. Its result lands in the verification status comment and is the one piece of evidence still outstanding — PR CI above is already green and the new behaviors are pinned by the targeted tests.

Not verified: the author's local Windows/Linux runs (not run here; web-shell unit tests are platform-independent under jsdom) and the author-run mutation numbers (superseded by the independent /verify runs).

中文说明

修复轮验证(0ab4f16): 作者推送了一个聚焦提交,逐一回应上一轮确认的五个发现。本次对照所审 head 逐一复核了修复、追踪了相关状态机并通读了新测试,五项全部成立。

  1. turn-error retry 再次失败(原为永久抑制)handleRetry 的 catch 在失败为 daemon turn error 时暂存被重试的 payload,新的 rearmFailedTurnErrorRetry不同的 turn_error 块出现后重新给出 retry——校验 owner 仍匹配、要求原错误块仍在 transcript 中(transcript 被替换则清空暂存,fail-closed),恢复 payload 引用并把 identity 指向新错误、清空 retriedTurnErrorIdRef 使 remap 分支不再抑制。两种 promptId 情形均按 effect 时序推演收敛。由 fresh/reused promptId 各一例的 it.each 钉住。附带诚实修复:套件原先的 isDaemonTurnError mock 恒为 false,旧测试根本无法覆盖该 catch 路径——这正是第一轮漏掉回归的原因,现已改为真实标记检测。
  2. 后台通知杀死 retry 入口:三处扫描均跳过 background_notification 块,渲染与 reconcile 重新一致;两类 retry 各有钉住测试。
  3. 新会话首个 prompt 失去可 retry 性:owner reset layout effect 在 owner 已匹配当前 connection 时提前返回(与上轮建议同形),reset 路径同时清空新暂存;关键是此前"因错误原因通过"的测试已修正——connection 提交移到 submit continuation 写入 refs 之后的 act() 内,按真实 React 时序触发 layout effect,移除提前返回即会红。
  4. handleRetry catch 缺失恢复!admitted 失败现与 handleFailedPromptRetry 同路径恢复,两条 retry 路径对齐;由网络拒绝测试钉住。
  5. copy-on-write 下乐观块 identity:按建议落地设计决策——allowLocalId 兜底仅在 retry owner 快照仍 current 时启用,同 owner 克隆继续按本地 id 匹配,跨 owner/attachment 的 id 复用保持 fail-closed;五个门控一致应用,外加 replayed 稳定记录的重绑定分支;由克隆测试与 replay 测试钉住。

另:cancelled-retry 条目引入单调 attemptId 并按 kind 归并,乱序准入结算保留较新 retry,有专门测试,设计契约同步补充一句。作者 inline 回复与修复一一对应;延期的 S 级项按五轮规则逐条记录。

非阻塞备注:CancelledRetryState.errorId 仍只写不读(已延期的清理项);applyCancelledRetryState 未用本地 id 兜底,但被围栏隔离的失败块在用户处于其他会话时实际不可能被克隆,属理论 corner;新 head 的沙箱 /verify 将在本提交之上重跑 A/B 与突变矩阵,见下。

CI 证据:所审 head CI 全绿,零失败(表格如上,finalize 会自动更新该区域)。fork PR 常规 skipped 项不构成缺口。沙箱验证:第一次 /verify 已在前一 head 上证明导航侧负载性声明(同一测试文件在 base 构建上 32 红、PR 构建全绿;六个单点守卫突变体全部被杀且无脱靶);第二次 /verify 正在本 head 上运行,将覆盖本次新增的重新武装机制,结果见验证状态评论——这是唯一未落定的证据,PR CI 已全绿且新行为均有定向测试钉住。未验证:作者本地 Windows/Linux 运行(web-shell 单测经 jsdom 平台无关)与作者自报 mutation 数字(以独立 /verify 为准)。

Qwen Code · qwen3.8-max

Reviewed at 0ab4f1623706dce2e3915287b78322d70a9c4fee · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the fix round landed exactly where the last review said the seams were, every restored affordance now has a pinning test, and CI is green; the one open item is the in-flight sandboxed re-verification of this head.

Going back to the independent proposal from the first pass — re-arm on a new same-session error instead of wipe/remap, and skip the owner reset when the submit continuation already advanced the owner — this commit implements precisely those two seams, plus the three supporting fixes, without scope creep. The regression I called deterministic in both directions (fresh and reused promptId) now has a two-armed it.each, and the first-prompt test that previously passed for the wrong reason was re-ordered to exercise real React timing — that is the kind of test fix that signals the author understood the finding rather than silencing it. The owner-scoped local-id fallback is the right design call: it trades exactly the sliver of anti-replay strictness needed to make optimistic blocks clone-proof, and an unconditional fallback would have reopened the collision bug this PR exists to fix.

The remaining nits are non-blocking and recorded: CancelledRetryState.errorId is still a dead field, App.tsx crossed the 1,000-production-line advisory and wants a lifecycle-extraction follow-up, and the second /verify run on this head is still in flight. None of that gates merge — PR CI is fully green, the behaviors are pinned by tests, and the first /verify already proved the underlying navigation guards by A/B.

Review-state bookkeeping, since this thread has history: this approval supersedes the bot's earlier changes-requested review (same account, latest review wins — that review gated the previous head 0b79905, whose findings this commit addresses). @wenshao's changes-requested also stands against the old head and its three findings are the ones fixed here — leaving re-review/dismiss to them. @yiliang114 already approved this head.

中文说明

总体判断:4/5 —— 修复轮恰好落在上轮指出的接缝上,每个恢复的入口都有钉住测试,CI 全绿;唯一未落定项是本 head 的沙箱复验仍在运行。

回到第一轮的独立方案——新同会话错误出现时重新武装而非 wipe/remap、submit continuation 已推进 owner 时跳过 owner reset——本提交精确实现了这两个接缝及三项配套修复,无范围蔓延。此前判定为双向确定性的回归(fresh/reused promptId)现有双臂 it.each 覆盖;曾因错误原因通过的 first-prompt 测试已按时序重排、在真实 React 时序下执行——这类测试修正表明作者理解了发现本身而非仅仅消除红灯。owner 作用域的本地 id 兜底是正确的设计决策:只让出使乐观块抗克隆所需的最小反重放严格度,无条件兜底会重新打开本 PR 要修复的 id 碰撞问题。

其余均为非阻塞并已记录:CancelledRetryState.errorId 仍是死字段;App.tsx 越过 1,000 行生产代码提示线,后续应抽出生命周期逻辑;第二次 /verify 仍在运行。均不构成合并门禁——PR CI 已全绿、行为有测试钉住、且第一次 /verify 已用 A/B 证明底层导航 guard。

Review 状态记录:本批准覆盖机器人此前的 changes-requested(同一账号最新评审生效,该评审门禁的是旧 head 0b79905,其发现已由本提交处理)。@wenshao 的 changes-requested 同样落在旧 head 上,其三项发现正是本次修复内容,是否复审/撤销由其决定。@yiliang114 已批准当前 head。

Qwen Code · qwen3.8-max

Reviewed at 0ab4f1623706dce2e3915287b78322d70a9c4fee · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Screenshots · before / after

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

  • packages/web-shell/client/App.tsx

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

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

Qwen Code · web-shell visuals

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship — CI landed green after the review. ✅

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): PR #8955 "fix(web-shell): Harden prompt admission ownersh...: none — all planned checks completed (diff fully read, suite executed, two mutation runs performed and reverted).; You are review agent reverse-audit — Reverse audit agen...: none — all checks above completed; no probe/mutation executed for finding 2 (stated in its confidence).; You are review agent reverse-audit — Reverse audit agen...: none — the full assigned range was read and all candidate checks above were completed.; chunk 5: none — I completed all checks I planned. Remaining: nothing unfinished. I have not verified the hunk contents past line 5172 (chunk 6), and cross-file callers o…; PR #8955 "fix(web-shell): Harden prompt admission ownersh...: none — all planned checks completed within budget., and 6 more.

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

中文说明

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未探索到全部深度(达到工具调用预算):PR #8955 "fix(web-shell): Harden prompt admission ownersh...:none — all planned checks completed (diff fully read, suite executed, two mutation runs performed and reverted).;You are review agent reverse-audit — Reverse audit agen...:none — all checks above completed; no probe/mutation executed for finding 2 (stated in its confidence).;You are review agent reverse-audit — Reverse audit agen...:none — the full assigned range was read and all candidate checks above were completed.;chunk 5:none — I completed all checks I planned. Remaining: nothing unfinished. I have not verified the hunk contents past line 5172 (chunk 6), and cross-file callers o…;PR #8955 "fix(web-shell): Harden prompt admission ownersh...:none — all planned checks completed within budget.,另有 6 条。

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

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

Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/App.test.tsx
Comment thread packages/web-shell/client/App.test.tsx
Comment thread packages/web-shell/client/App.test.tsx
Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/App.tsx

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not explored to full depth (tool budget reached): This PR hardens WebShell prompt ownership across session ...: none — all planned checks completed within budget.; This PR hardens WebShell prompt ownership across session ...: I did not verify whether the daemon server unconditionally assigns SSE event IDs to turn_error events (that code is outside the web-shell package). If the dae….

[Critical] R1-3: retry owner useLayoutEffect's general reset path clobbers submit-state refs during session creation. When a user opens a new chat (no active session) and submits a prompt, ensureSessionForPrompt() allocates a session, calling setConnection inside createSession. The submit flow continuation (microtask) sets lastSubmittedPromptRef and retryOwnerRef before React processes the setConnection state update. The subsequent retry owner useLayoutEffect fires, finds the owner already matches (so workspaceBecameKnown is false), falls through to the general reset, and clobbers lastSubmittedPromptRef = '' and lastSubmittedSourceVersionRef = -1. When a turn error arrives, canRetry checks lastSubmittedPromptRef.current.length > 0 which is false — the retry hint is never shown for the first prompt after session creation. Confirmed by a timing probe: the layout effect fires after the async continuation. The existing test 'keeps the first prompt retryable through its lazy session commit' passes only because its mock bypasses setConnection by mutating mockConnection directly, which does not trigger the layout effect at the correct time. Suggested fix: add a guard at the top of the layout effect that returns early when the owner already matches the current connection state.

中文说明

未探索到全部深度(达到工具调用预算):This PR hardens WebShell prompt ownership across session ...:none — all planned checks completed within budget.;This PR hardens WebShell prompt ownership across session ...:I did not verify whether the daemon server unconditionally assigns SSE event IDs to turn_error events (that code is outside the web-shell package). If the dae…

[Critical] R1-3: retry owner useLayoutEffect's general reset path clobbers submit-state refs during session creation. When a user opens a new chat (no active session) and submits a prompt, ensureSessionForPrompt() allocates a session, calling setConnection inside createSession. The submit flow continuation (microtask) sets lastSubmittedPromptRef and retryOwnerRef before React processes the setConnection state update. The subsequent retry owner useLayoutEffect fires, finds the owner already matches (so workspaceBecameKnown is false), falls through to the general reset, and clobbers lastSubmittedPromptRef = '' and lastSubmittedSourceVersionRef = -1. When a turn error arrives, canRetry checks lastSubmittedPromptRef.current.length > 0 which is false — the retry hint is never shown for the first prompt after session creation. Confirmed by a timing probe: the layout effect fires after the async continuation. The existing test 'keeps the first prompt retryable through its lazy session commit' passes only because its mock bypasses setConnection by mutating mockConnection directly, which does not trigger the layout effect at the correct time. Suggested fix: add a guard at the top of the layout effect that returns early when the owner already matches the current connection state.

— glm-5.2 via Qwen Code /review (v0.21.10)

Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/App.tsx
yiliang114
yiliang114 previously approved these changes Aug 12, 2026

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewing for the admission-ownership invariants (series #8678). Full diff read (App.tsx +818/-160, design doc, test suite). Approving — no P0/P1 found.

Ownership/fencing

  • Admission source (owner snapshot, sessionId, workspace cwd, source version, write-gate generation) is captured before onSubmitBefore and rechecked after it, after ensureSessionForPrompt, and before composer commit / clearFollowup / lastSubmitted recording — so draft and retry state stay with the source composer when the gate closes mid-flight. ownAllocationSucceeded correctly exempts only the prompt that allocated the session itself, and requires the connection to have adopted that allocation.
  • Enqueue path adds sourceOwner.isCurrent(), plan-mode prep re-checks the write-gate generation before sending, and deferComposerCommit now also covers an in-flight shared lazy-session creation. All consistent with the updated design doc.

Transcript identity

  • Replacing raw block-id matching with reference identity + stable fallbacks (sourceRecordIds for user messages, promptId/eventId for turn errors) is the right fix against transcript replacement reusing local ids; the empty-transcript anchor case is handled and mismatches fail closed. setFailedPromptRetry updaters now key on the identity-object reference, avoiding sessionId/messageId collisions.

Cancelled-retry deferral

  • The session-keyed defer/restore queue restores only when writes are unblocked, the transcript is loaded, and (for workspace-unknown entries) the captured owner is still current or the same owner supplied the workspace (re-key migration); an owner change discards. Unmount invalidates continuations via appMountedRef. The Symbol-keyed promptPreparationOwnerRef prevents a stale finally from clobbering a newer preparation, and both retry entry points are gated on it.

Tests pin the invariants behaviorally (ownerVersion bumps, transcript replacement with reused local error ids, A→B→A owner cycles, admission responses lost mid-flight, workspace enrichment), asserting sendPrompt call counts and UI state rather than snapshots.

CI (head 0b79905): Qwen Code CI green — Test (ubuntu, Node 22) success (includes the web-shell suite), web-shell E2E Smoke success, Desktop Shell ubuntu/windows success; windows/macOS matrix and CLI integration jobs skipped by design. No flake failures.

Non-blocking nits (P3):

  1. In applyCancelledRetryState's failed-prompt branch, if identity matches via sourceRecordIds but the block id changed (rehydrated transcript), displayMessages.some(id === failed.messageId) never becomes true, so the entry stays pending until an owner change clears it. handleFailedPromptRetry remaps messageId in the analogous case; doing the same here would close the gap. Fail-safe direction (retry affordance not restored), very narrow.
  2. The restore layout effect re-runs on every transcript frame while pending entries exist — negligible cost today, just worth keeping in mind if the transcript update cadence grows.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 3343 passed · 0 failed · 3343 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

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

脚本断言:3343 通过 · 0 失败 · 3343 总计

Verification report

PR #8955 Deep Verification — fix(web-shell): Harden prompt admission ownership

Verdict: merge-ready — 3343 scripted assertions executed, 0 unexpected failures.
Verified head: 0b7990568105150dc9b381c8b75732e7df055286 (merge ref 0911747636, base tip 0e8a361b37).

中文摘要
  • 结论: merge-ready。断言总数与意外失败数见报告首行及 assertions.json
  • A/B 结论: 中央声明(导航 A→B(→A) 之后返回的异步 continuation 不产生副作用)经负载性证明,详见 "Central claim — A/B load-bearing proof" 表:同一份 HEAD 测试文件在 HEAD 构建上全绿、在 base(HEAD^1)构建上 32 红,32 个测试精确地由 broken 翻转为 fixed,全部是行为断言失败(过期 sendPrompt/enqueue 被调用、过期 retry UI 出现),无一为 harness/收集错误。
  • 突变矩阵: 见 "Mutation matrix" 表:6 个单点守卫回退突变体全部被测试套件杀死,零幸存者,且每个突变体只杀其目标测试族(无脱靶);未突变对照在同一 worktree 中全绿。
  • 门禁: 见 "gates-assert" 结果(report 的 Methodology/Not covered 各节引用同一数字):web-shell 工作区全套通过;tsc --noEmit 与 ESLint 均为零输出(两者均经"植入违规必被捕获"的活性验证);真实 daemon 会话切换套件 5/5 通过(与 PR 声明一致)。
  • Findings: 无阻塞项。无纠正项。PR 文本未发现引导性注入指令。
  • 未覆盖范围: Playwright e2e/视觉套件、会话切换文件之外的集成套件、Windows/macOS 平台特性(本容器为 Linux,单元测试经 jsdom 平台无关地通过)、DataWorks 宿主控制器行为;6 个主要守卫族之外的更细粒度突变(如 plan-prompt generation 检查)仅由 base 红单元格间接覆盖,未单独做单点突变。

Scope selection

Central claim — asynchronous continuations (host admission hook onSubmitBefore, lazy session preparation, queued-prompt admission) that return after a session navigation (A→B or A→B→A) or App unmount are side-effect-free: no stale send, no stale enqueue, no composer commit/clear, no follow-up clear.

Secondary claims

  1. Cancelled failed-prompt / turn-error retries are retained for their logical source session and restored only after navigation settles; a different owner or a transcript that merely reuses reducer-local block IDs fails closed.
  2. Retry identity matching uses stable identities (same in-memory block, persisted sourceRecordIds, promptId/eventId) — a stable replay keeps exactly one retry.

Diff under test: packages/web-shell/client/App.tsx (+818/−160), packages/web-shell/client/App.test.tsx (+3368/−1262), design doc (+29/−6). Single commit; git rev-list HEAD^1..HEAD^2 = 1 commit matching the metadata snapshot's commits array.

Central claim — A/B load-bearing proof

The identical HEAD test file (422 tests) was run against both builds:

Arm Tree Production code Oracle Result
head merge ref 0911747636 PR head App.tsx vitest, mocked daemon adapters, real component tree 422/422 pass
control worktree at HEAD^1 = 0e8a361b37 base App.tsx same test file, copied verbatim into the base tree 32 failed / 390 passed

Witnesses: 01-ab-head-arm-422-green.png (head arm as it printed), 02-ab-base-arm-32-red.png (control arm as it printed).

All 32 red cells are behavioral assertion mismatches, e.g.:

  • base arm, direct-send race: AssertionError: expected "spy" to not be called at all, but actually been called 1 times — the base sent the stale prompt (sendPrompt("first", …)) after the navigation changed the owner; head cancels it.
  • base arm, queued A→B→A cycle: same assertion on the enqueue spy — the base enqueued the stale prompt because its sessionId-equality check passes again once navigation returns to A; the PR's owner-snapshot check is what closes that hole.
  • base arm, reused local error id: expected "spy" to be called 1 times, but got 2 times plus stale retry buttons still rendered — the base offered a retry for a replacement transcript that reused the reducer-local block id.

Central-claim cells (each red on base, green on head):

Scenario Test base head
direct submit, navigation during lazy session prep cancels an approved submission when navigation occurs during session preparation ❌ stale send ✅ cancelled
default submit, navigation during prep cancels a default submission when navigation occurs during session preparation ❌ stale send ✅ cancelled
approved submit, App unmounted before hook returns does not commit an approved submission after the App unmounts ❌ sends ✅ inert
queued submit, A→B→A owner cycle cancels an approved queued submission after an A-to-B-to-A owner cycle ❌ stale enqueue ✅ cancelled
queued submit, App unmounted does not enqueue an approved queued submission after the App unmounts ❌ stale enqueue ✅ cancelled
cancelled retry during navigation defers retry restoration until navigation commits ❌ restores early ✅ deferred
replacement transcript reuses local error id drops a known-workspace retry when a replacement reuses its local error id ❌ stale retry ✅ dropped
stable replay (positive control) keeps a known-workspace retry across a stable error replay ✅ green on both arms ✅ green

The full 32-name red set is asserted exact-set by harness/ab-assert.mjs (16/16 checks pass): no missing, no extra red cells. Of the 32, 29 are genuinely new tests introduced by this PR and 3 are pre-existing tests whose bodies were strengthened (e.g. settles a prompt retry after a same-id attachment replacement). 10 further new tests pass on both arms — regression-style coverage for behavior the base already had.

Control purity: the base worktree reuses the main tree's node_modules (symlinked); the PR touches no package.json/package-lock.json, and the internal workspace deps resolve identically on both arms (readlink -f node_modules/@qwen-code/sdkpackages/sdk-typescript, unchanged by this PR). The only difference between arms is App.tsx at HEAD^1 vs head.

Mutation matrix (vacuity + per-guard load-bearing proof)

Unmutated control in the mutation worktree: 422/422 green (same harness, same node_modules). One guard reverted per mutant; full 422-test suite per mutant. Witness: 03-mutation-matrix-six-mutants.png (live re-run of all six mutants).

Mutant Guard reverted Suite result Killed tests (exact set asserted)
M1 post-ensureSessionForPrompt admission recheck (send path) 2 failed / 420 passed both navigation-during-preparation cancellation tests
M2 enqueue-path appMountedRef + owner-snapshot checks 2 failed / 420 passed A→B→A queued cycle; unmount enqueue
M3 matchesTurnErrorIdentity → reducer-local id only 4 failed / 418 passed reused-error-id drop, stable-replay keep, stale turn-error restore, uncertain-response drop
M4 matchesUserMessageIdentity → reducer-local id only 6 failed / 416 passed reused-user-id drop, rehydration, restore-after-switching, anchor-change drop, same-id settle, uncertain failed-response drop
M5 sessionWriteBlocked in the cancelled-retry restore gate 3 failed / 419 passed deferred restoration; cached-retry migration; workspace-unknown owner drop
M6 side-effect ordering reverted (composer commit/clear + follow-up clear moved before the gate, gate itself intact) 8 failed / 414 passed both nav-prep tests failing at expect(editorClear).not.toHaveBeenCalled() (App.test.tsx:11170), plus the onSubmitBefore-reject and lazy-session families

Zero survivors; zero off-target kills in any mutant (each killed set is exact, asserted by harness/matrix-assert.mjs). Notes:

  • M3 kills in both directions: id-only matching both drops the reused-id impostor test and loses the stable-replay keep test — the stable-identity machinery is what makes the two distinguishable at all.
  • M6 is the finer-mutation control: with the gate still present, sendPrompt is never called, but the eager editorRef.current?.clear() leaks — proving the ordering change (side effects moved after the rechecks) is independently pinned, not just the gate's existence.
  • Positive control: the unmutated run is green and every mutant went red on the intended behavioral assertions (expected-vs-received spy-call messages), so the harness demonstrably can fail.

Reviewer Test Plan, walked step by step

  1. Delayed onSubmitBefore / lazy prep, direct + queued submission, A→B→A, then release — no stale send/enqueue, composer and follow-up intact, retry only after settled. — Exercised by the nav-prep cancellation cells (both submission kinds), the unmount cells, the queued A→B→A cell, and the restoration cells (defers retry restoration until navigation commits, restores … after switching away during admission, waits for the source transcript before restoring a cancelled retry). Composer/follow-up intactness is pinned by the M6-sensitive assertions. ✅ all green on head, red on base where the guard is new.
  2. Failed-prompt and turn-error retries under attachment replacement; stable replay keeps exactly one retry; reused local IDs expose nothing. — Exercised by the M3/M4 test families incl. does not expose a duplicate retry when a stable replay passes through an empty transcript and rehydrates a rejected failed-prompt retry after attachment reset. ✅ green on head.
  3. Hanging real daemon turn, A→B→A, source stays active, ledger clean. — Real-daemon integration suite integration-tests/cli/qwen-serve-webui-session-switching.test.ts: 5/5 pass on the merge ref (24.6 s), incl. keeps an active source running across A to B to A navigation. Witness log: logs/session-switching-head.log. ✅ matches the PR's claimed 5/5.

Shape-vs-cause note: the unit-level cells reproduce the wire shape of the race (pending continuations resolved after simulated navigation of the mocked connection) rather than real host-hook latency against a live daemon; the real-daemon suite covers the daemon/session layer around navigation but drives webui primitives, not the App guards. Together they bracket the claim from both sides.

Corrections

None required — no inaccurate claims from earlier review rounds were encountered, and the PR description's quantitative claims (422/422 App suite, 5/5 session switching, mutation checks failing when owner/generation/transcript-identity guards are removed) were each independently reproduced.

Findings

None blocking. Observations, for the record:

  1. (Info) The PR's own test-plan platform table marks Linux as untested (⚠️). This round ran the entire unit suite and the real-daemon suite on Linux (node:22-bookworm, Node v22.23.2) — all green; the component tests are jsdom-based and platform-agnostic.
  2. (Info) The write-gate generation guard was not isolated as a standalone mutant: the generation recheck on the onSubmitBefore path pre-exists on base, and the new plan-prompt generation check is pinned indirectly (does not send a deferred plan prompt after an interrupted navigation is among the 32 base-red cells). Owner and transcript-identity guards were isolated (M2/M3/M4), matching the PR's "owner, generation, or transcript-identity" claim for two of the three named axes directly and the third indirectly.
  3. (Info) No injection-style steering instructions were found in the PR title/body/commit message.

Not covered

  • Playwright e2e and visual suites (test:e2e, test:e2e:visuals) — browser-based, out of scope for this round.
  • The rest of the integration suite (cli/interactive beyond the session-switching file).
  • Windows/macOS platform-specific behavior (container is Linux; unit tests run under jsdom).
  • DataWorks host-controller behavior, daemon prompt/cancel semantics beyond the session-switching suite, branch/fork navigation — declared out of scope by the PR and not exercisable here.
  • Finer single-point mutants beyond the six guard families (e.g. the workspaceBecameKnown retry migration, the plan-prompt generation check, the shell-command preparation owner) — covered by the base-red cell set but not individually mutated.
  • Per-commit attribution is trivially complete: exactly one commit in the PR.

Methodology

Environment: CI verify container (node:22-bookworm), Node v22.23.2, npm ci + npm run build pre-run at the merge ref. A/B control built as git worktree add tmp/base-tree HEAD^1 with the HEAD test file copied in and the package's node_modules symlinked (lockfile untouched by the PR; readlink -f asserts internal workspace deps resolve to unchanged packages). Mutations ran in a second worktree (tmp/head-mut, merge ref), applied as single-hunk patches generated by harness/make-patches.py, each reverted before the next. Suites: npx vitest run client/App.test.tsx --config vitest.config.ts (~30 s/run) and the workspace-wide npx vitest run; real-daemon suite via cross-env QWEN_SANDBOX=false npx vitest run cli/qwen-serve-webui-session-switching.test.ts from integration-tests/. Gate liveness proven by planted violations (ESLint caught 2 planted errors, exit 1; tsc reported TS2322 for a planted type error) then removed. Scripted assertion harnesses: harness/ab-assert.mjs (16 checks), harness/matrix-assert.mjs (12 checks), harness/gates-assert.mjs (10 checks); evidence images via scripts/verify-capture.mjs. Raw per-arm and per-mutant logs: logs/.

Evidence images

01-ab-head-arm-422-green

02-ab-base-arm-32-red

03-mutation-matrix-six-mutants

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

The bot already has a review of its own on 0b7990568105150dc9b381c8b75732e7df055286, which still stands.

机器人在 0b7990568105150dc9b381c8b75732e7df055286 上已有自己的评审,且仍然有效。

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

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

ytahdn
ytahdn previously approved these changes Aug 12, 2026

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

LGTM. No critical issues found. Key suggestions: (1) workspace check in admissionOwnerIsCurrent may be over-permissive when the original workspace was known — consider only waiving the check when workspaceCwd was undefined; (2) retryOwnerRef layout effect mutates previousOwner fields in-place, prefer immutable updates; (3) stale entries may accumulate in cancelledRetryStatesRef when known workspace changes; (4) no test coverage for queued-prompt owner/write-gate cancellation path. Also check whether sessionOwnerGuard is referentially stable across renders to avoid unnecessary effect re-runs.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC
doudouOUC dismissed stale reviews from ytahdn and yiliang114 via 0ab4f16 August 12, 2026 08:06
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Handled the current review round in commit 0ab4f16237.

Action Review items Resolution
Fixed R1-1, R1-34 Re-arm a retry only after an admitted retry is authoritatively rejected by daemon turn_error and a distinct live error block arrives; covers fresh and reused promptId with provider-accurate event ordering.
Fixed R1-12 Ignore background_notification user-kind blocks in failed-prompt and turn-error retry identity scans.
Fixed differently wenshao R1-1 Permit reducer-local ID fallback only while the captured live attachment is current; stable sourceRecordIds remain the cross-attachment identity.
Fixed wenshao R1-2 Restore a turn-error retry when resend fails before admission starts.
Fixed wenshao R1-3 Preserve lazy-session prompt retry state when the allocated session connection commit lands after the submit continuation.
Fixed yiliang nit 1, R1-29 Remap restored failed-prompt retry state onto a replayed stable user record, with changed local ID and display text coverage.
Fixed out-of-order retry admissions Keep the newest cancelled retry of each kind when async callbacks settle out of order.
Deferred R1-4, R1-5, R1-6, R1-7, R1-8, R1-10, R1-15, R1-17, R1-19, R1-26, R1-27, R1-28, R1-31 Non-blocking coverage, cleanup, and cache-policy suggestions are deferred under the repository rule to accept only correctness fixes after roughly five review rounds. No production behavior for these items was changed.

Verification on the exact commit:

  • WebShell App tests: 430/430
  • npm run build
  • npm run typecheck
  • npm run lint
  • npm run bundle
  • Real daemon session-switching integration: 5/5
  • Two consecutive clean open-ended diff audits

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Fresh sweep of the new head (delta from 0b799050ab4f16, one commit, 3 files) against the Critical findings from the prior rounds. Approving — no P0/P1.

Critical fixes — verified

  • Layout-effect clobber on lazy session creation (wenshao R1-3): the retry-owner layout effect now returns early when retryOwnerMatchesCurrent(previousOwner, …) (App.tsx ~4213), placed after the workspaceBecameKnown re-key branch and before the general reset. The send continuation writes the full owner object (incl. snapshot), so previousOwner is authoritative when the effect fires post-setConnection — submit-state refs survive, and a genuine session switch still falls through to reset. The previously masking test was restructured to commit the connection change through a real rerender after send, so the fix is pinned.
  • Failed retry could never be retried again (R1-1 wipe / R1-34 promptId-reuse suppression): new failedTurnErrorRetryRef stash + rearmFailedTurnErrorRetry. The catch stashes the payload on isDaemonTurnError, and the re-arm runs at the top of the blocks effect, re-pinning identity onto the distinct new error and nulling retriedTurnErrorIdRef before the remap/wipe branches evaluate. Fail-closed is retained: re-arm requires the captured owner still current AND the original error block still present in the transcript (transcript replacement discards the stash), and the send path + wipe branch both clear it. I traced the admitted/not-admitted orderings against the deferred-queue restore and the settled && idle clearing effect — the mechanisms converge rather than fight.
  • background_notification user-kind blocks (R1-12): skipped in getLatestUserBlock/findUserMessageByIdentity, and getRetryableTurnError scans past them instead of breaking — both retry affordances survive notification injection.
  • No local-id fallback (wenshao R1-1): allowLocalId added to matchesUserMessageIdentity/findUserMessageByIdentity, gated on owner.snapshot.isCurrent() at all five call sites — owner-scoped, no unconditional id fallback, so cross-attachment id collisions stay closed.
  • handleRetry catch missing restore (wenshao R1-2): !admitted now routes through restoreOrDeferCancelledRetry, mirroring the failed-prompt path.
  • Bonus hardening: monotonic attemptId + mergeCancelledRetryEntries so a newer retry supersedes an older same-kind retry when admission callbacks settle out of order; design doc updated with the invariant. Also closes my earlier P3 nit — applyCancelledRetryState now remaps messageId/identity onto the matched replayed record instead of parking pending forever.

Tests: each fix is pinned behaviorally — reused-vs-fresh promptId re-offer matrix, resend-fails-before-admission restore, out-of-order admission supersede (asserts the newer payload is resent), background-notification visibility for both retry kinds, replayed-record restoration with changed block id + display text, and the restructured lazy-session first-prompt test.

CI (0ab4f16): Test (ubuntu, Node 22) ✅ incl. web-shell suite, web-shell E2E Smoke ✅, Desktop Shell ubuntu/windows ✅; macOS/windows Test and CLI integration skipped by design. No flake failures. Remaining in-flight/cancelled checks are bot automation (review-pr, route), not CI gates.

Non-blocking nits (P3):

  1. The re-arm's replacement guard keys on block.id === failedRetry.errorId presence rather than reference identity; a replayed transcript reusing that local error id could keep the stash alive. Narrow and bounded (owner must stay current, restored payload is the user's own last prompt) — reference-based presence would be marginally tighter.
  2. retryTranscriptIsCurrent in handleRetry omits the allowLocalId flag its failed-prompt counterpart passes — intentional given promptId/eventId-based turn-error identity, but worth a one-line comment someday.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 7259 passed · 0 failed · 7259 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

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

脚本断言:7259 通过 · 0 失败 · 7259 总计

Verification report

PR #8955 Deep Verification (round 2) — fix(web-shell): Harden prompt admission ownership

Verdict: merge-ready — 7259 scripted assertions executed, 0 unexpected failures.
Verified head: 0ab4f1623706dce2e3915287b78322d70a9c4fee (merge ref 019664d1dc, base tip 1570e6c73f).

This is a follow-up round. Round 1 verified head 0b799056 (then-single commit) and reported merge-ready with three informational findings. The PR has since gained a second commit, 0ab4f162 "Address prompt retry review feedback", and the base advanced (0e8a361b1570e6c73f). The first commit is not reachable in this depth-2 checkout, so the delta between the two commits cannot be isolated; all round-1 measurements were re-run from scratch at the new aggregate head — nothing was carried forward on the input-closure shortcut, because the code under test changed.

中文摘要
  • 结论: merge-ready(新 head 0ab4f162)。断言总数与意外失败数见报告首行及 assertions.json。本轮为第二轮跟进验证:PR 新增第二个提交且 base 前移,第一轮的全部测量均在新 head 上重新执行,未沿用旧数字。
  • 上一轮发现状态: 见 "Previous findings status" 表 — 第一轮仅有 3 条 Info 级发现,均在新 head 上重新测量:Linux 全套依然全绿、generation 守卫仍由 base 红单元格与 M1 突变体间接钉住、PR 文本重新扫描后仍无注入式引导指令。
  • A/B 结论: 中央声明(导航 A→B(→A) 或卸载之后返回的异步 continuation 不产生副作用)经负载性证明,详见 "Central claim — A/B load-bearing proof" 表:同一份 430 测试文件在 head 构建上全绿、在 base(HEAD^1)构建上 37 红,37 个测试全部精确地由 broken 翻转为 fixed,且全部是行为断言失败(过期 sendPrompt/enqueue spy 被调用、过期 retry UI 出现),无一为收集/harness 错误。37 个红测试全部为本 PR 新增测试。
  • 突变矩阵: 见 "Mutation matrix" 表 — 6 个单点守卫突变体全部被杀死(M1:2、M2:2、M3:4、M4:7、M5:3、M6:6,共 24 次击杀),零幸存者、零家族外误杀(脚本断言),未突变对照同树全绿(430/430)。
  • 门禁: 见 "gates-assert" 结果 — web-shell 工作区全套 181 文件 3333/3333 通过;tsc --noEmit 与 ESLint 零输出(均经植入违规活性验证);真实 daemon 会话切换套件 5/5 通过。
  • Findings: 无阻塞项、无纠正项。Info 项见 Findings 节(webui 包 act() 警告噪声、generation 守卫覆盖方式、无注入)。
  • 未覆盖范围: 两个 PR 提交之间的逐提交差分归因(浅克隆不可达)、Playwright e2e/视觉套件、会话切换之外的集成套件、Windows/macOS 平台、DataWorks 宿主控制器与 branch/fork 导航、6 族之外的更细粒度单点突变。

Previous findings status

Round 1 (merge-ready at head 0b799056) reported no blocking findings — three Info items:

# Round-1 finding Severity Status at new head 0ab4f162
1 Author's platform table marks Linux untested (⚠️) Info Stands (author table unchanged). Re-measured here on Linux (node:22-bookworm, Node v22.23.2): App suite 430/430, workspace suite 3333/3333, real-daemon suite 5/5 — all green at the new head.
2 Write-gate generation guard not isolated as a standalone mutant; pinned indirectly via does not send a deferred plan prompt after an interrupted navigation among base-red cells Info Stands, partially strengthened. That test is still among the 37 base-red cells at the new head; additionally mutant M1 deletes admissionSourceIsCurrent(), which contains the generation comparison, so the guard is covered inside M1's 2 kills. A generation-only single-point mutant was still not run (see Not covered).
3 No injection-style steering instructions in PR text Info Stands. Round-2 metadata (title, body, both commit messages) re-scanned: no steering content.

No declined or deferred rows existed in round 1, so there is nothing to re-adjudicate there.

Scope selection

Central claim — asynchronous continuations (host admission hook onSubmitBefore, lazy session preparation, queued-prompt admission) that return after a session navigation (A→B or A→B→A) or App unmount are side-effect-free: no stale send, no stale enqueue, no composer commit/clear, no follow-up clear.

Secondary claims

  1. Cancelled failed-prompt / turn-error retries are retained for their logical source session and restored only after navigation settles; a different owner or a transcript that merely reuses reducer-local block IDs fails closed.
  2. Retry identity matching uses stable identities (same in-memory block, persisted sourceRecordIds, promptId/eventId) — a stable replay keeps exactly one retry.

Diff under test (aggregate HEAD^1..HEAD): packages/web-shell/client/App.tsx (+986/−160), packages/web-shell/client/App.test.tsx (+5637/−3122), design doc (+31/−6). No package.json/lockfile changes anywhere; no files outside web-shell + docs (verified by git diff --stat). The metadata snapshot lists 2 commits, but git rev-list HEAD^1..HEAD^2 returns only the grafted head commit — per-commit attribution is out of reach (see Not covered). Note the snapshot's baseRefOid (0e8a361b…) is stale relative to the rebuilt merge ref; per the environment contract HEAD^1 (1570e6c73f) is the authoritative base tip, and headRefOid matches HEAD^2 exactly.

Central claim — A/B load-bearing proof

The identical HEAD test file (430 tests — round 1 had 422; the second commit added 8) was run against both builds:

Arm Tree Production code Oracle Result
head merge ref 019664d1dc PR head App.tsx vitest v3.2.4, mocked daemon adapters, real component tree 430/430 pass
control worktree at HEAD^1 = 1570e6c73f base App.tsx same 430-test file, copied verbatim (sha256-verified) 37 failed / 393 passed

Witnesses: 01-ab-head-arm-430-green.png (head arm summary as printed), 02-ab-base-arm-37-red.png (control arm summary as printed), 04-ab-assert-flip-set.png (scripted flip-set assertions as printed).

All 37 red cells are behavioral assertion mismatches — e.g. base arm, direct-send race and unmount cells: AssertionError: expected "spy" to not be called at all, but actually been called 1 times (the base sent the stale prompt after the owner changed / the App unmounted); queued cell: same shape on the enqueue spy. All 37 are tests newly introduced by this PR (title-census against the base test file: 37 new, 0 pre-existing; round 1 had 29 new + 3 strengthened — the strengthened titles were replaced by new ones in the second commit).

Representative cells (each red on base, green on head; full 37-name set asserted exact by harness/ab-assert.mjs):

Scenario Test base head
direct submit, navigation during lazy session prep cancels an approved submission when navigation occurs during session preparation ❌ stale send ✅ cancelled
default submit, navigation during prep cancels a default submission when navigation occurs during session preparation ❌ stale send ✅ cancelled
approved submit, App unmounted before hook returns does not commit an approved submission after the App unmounts ❌ sends ✅ inert
queued submit, A→B→A owner cycle cancels an approved queued submission after an A-to-B-to-A owner cycle ❌ stale enqueue ✅ cancelled
queued submit, App unmounted does not enqueue an approved queued submission after the App unmounts ❌ stale enqueue ✅ cancelled
cancelled retry during navigation defers retry restoration until navigation commits ❌ restores early ✅ deferred
replacement transcript reuses local error id drops a known-workspace retry when a replacement reuses its local error id ❌ stale retry ✅ dropped
replacement transcript reuses local user id drops a known-workspace failed retry when a replacement reuses its local user id ❌ stale retry ✅ dropped
stable replay (positive control) keeps a known-workspace retry across a stable error replay ✅ green on both arms ✅ green

The last row is the positive control run on both arms: base happens to keep the stable replay because its id-only matching also accepts the impostor — the M3 mutant below shows the head's stable-identity machinery is what keeps the replay while dropping the impostor.

Control purity: the base worktree reuses the root node_modules (walk-up resolution) plus a symlinked packages/web-shell/node_modules for the nested @datafe-open/* deps. The PR touches no package.json/lockfile, and internal workspace deps resolve identically on both arms (readlink -f node_modules/@qwen-code/sdkpackages/sdk-typescript, @qwen-code/webuipackages/webui; git diff --stat over those packages = 0 lines). The only difference between arms is App.tsx at HEAD^1 vs head. (Initial base-arm run failed collection on @datafe-open/markdown-chart until the nested node_modules was linked — a harness issue, fixed and re-run; the failing attempt is preserved in logs/base-arm.log history.)

Mutation matrix (vacuity + per-guard load-bearing proof)

Unmutated control in the mutation worktree (tmp/head-mut, merge ref): 430/430 green (same harness, same node_modules). One guard reverted per mutant via harness/apply-mutant.mjs (each anchor asserted to occur exactly once); full 430-test suite per mutant. Witness: 03-mutation-matrix-six-mutants.png (live run of harness/matrix-assert.mjs, 25/25 checks).

Mutant Guard reverted Suite result Killed tests
M1 post-ensureSessionForPrompt admission recheck (send path, incl. generation comparison) 2 failed / 428 passed both navigation-during-preparation cancellation tests
M2 enqueue-path appMountedRef + owner-snapshot checks 2 failed / 428 passed A→B→A queued cycle; unmount enqueue
M3 matchesTurnErrorIdentity → reducer-local id only 4 failed / 426 passed reused-error-id drop; stale turn-error restore; uncertain-response drop; stable-replay keep (both directions)
M4 matchesUserMessageIdentity → reducer-local id only 7 failed / 423 passed reused-user-id drop; rehydration; restore-after-switching; anchor-change drop; stable-user-record replay; same-id settle; uncertain failed-response drop
M5 sessionWriteBlocked in the cancelled-retry restore gate 3 failed / 427 passed deferred restoration; cached-retry migration; workspace-unknown owner drop
M6 side-effect ordering reverted (composer commit/clear + follow-up clear moved before the gate, gate intact) 6 failed / 424 passed both nav-prep tests; shared-rejected-preparation clear; lazy-session admission family (delivery-unknown scoping, allocation scoping, first-failed-message tracking)

Zero survivors; zero off-family kills (every killed name is asserted inside that mutant's curated family by harness/matrix-assert.mjs). Notes:

  • M3 kills in both directions again at the new head: id-only matching both admits the reused-id impostor (drop tests die) and loses the stable-replay keep test — the positive-control row of the A/B and the M3 kill are the same two-sided evidence from opposite sides.
  • M6 is the finer-mutation control: with the gate still present, sendPrompt is never called, but the eager editorRef.current?.clear() / clearFollowup() leak — the nav-prep tests die on the intactness assertions, proving the ordering change is independently pinned, not just the gate's existence. Round 1's M6 killed 8; the second commit reshaped the lazy-session family, now 6.
  • M4 kills 7 (round 1: 6) — the new drops a cancelled failed-prompt retry when its transcript anchor changes test is additionally pinned.
  • Positive control: unmutated run green; every mutant went red on intended behavioral assertions (expected-vs-received spy-call and rendering messages), so the harness demonstrably can fail.

Reviewer Test Plan, walked step by step

  1. Delayed onSubmitBefore / lazy prep, direct + queued submission, A→B→A, then release — no stale send/enqueue, composer and follow-up intact, retry only after settled. — Exercised by the nav-prep cancellation cells (both submission kinds), the unmount cells, the queued A→B→A cell, and the restoration cells (defers retry restoration until navigation commits, restores … after switching away during admission, waits for the source transcript before restoring a cancelled retry). Composer/follow-up intactness is pinned by the M6-sensitive assertions. ✅ all green on head; red on base wherever the guard is new.
  2. Failed-prompt and turn-error retries under attachment replacement; stable replay keeps exactly one retry; reused local IDs expose nothing. — Exercised by the M3/M4 families incl. does not expose a duplicate retry when a stable replay passes through an empty transcript (green on both arms — regression-style) and rehydrates a rejected failed-prompt retry after attachment reset (base-red). ✅ green on head.
  3. Hanging real daemon turn, A→B→A, source stays active, ledger clean. — Real-daemon integration suite integration-tests/cli/qwen-serve-webui-session-switching.test.ts: 5/5 pass on the merge ref (24.5 s), incl. keeps an active source running across A to B to A navigation. Witness: logs/session-switching-head.log. ✅ matches the PR's claimed 5/5.

Shape-vs-cause note (unchanged from round 1): the unit cells reproduce the wire shape of the race (pending continuations resolved after simulated navigation of the mocked connection), not real host-hook latency against a live daemon; the real-daemon suite covers the daemon/session layer around navigation but drives webui primitives, not the App guards. Together they bracket the claim from both sides.

Corrections

None. No inaccurate claims from earlier rounds or bot comments were encountered; round-1's quantitative claims were reproduced at that round's head, and the PR description's claims (full App suite green, 5/5 session switching, mutation checks failing when owner/generation/transcript-identity guards are removed) were each independently reproduced at the new head (430/430, 5/5, and mutants M1–M6 all red).

Findings

None blocking. Observations, for the record:

  1. (Info) The real-daemon suite's stderr carries React act(...) warnings — An update to DaemonSessionProvider inside a test was not wrapped in act(...) — from the webui package's provider. This PR touches no webui code (git diff --stat outside web-shell/docs = 0 lines), and the suite passes 5/5; recorded as pre-existing test noise, not attributed to the PR. Base-attribution would require a full base bundle rebuild, not spent here.
  2. (Info, carried from round 1) The write-gate generation guard still has no standalone single-point mutant; it is pinned indirectly (base-red plan-prompt cell) and inside M1's deleted admissionSourceIsCurrent() gate.
  3. (Info) No injection-style steering instructions in the PR's title, body, or either commit message.

Not covered

  • Per-commit delta attribution. The metadata lists 2 commits (0b799056, 0ab4f162), but the depth-2 merge-ref checkout reaches only the grafted head (git rev-list HEAD^1..HEAD^2 = 1 commit; git cat-file -t 0b799056… fails). What changed specifically in the "review feedback" commit cannot be isolated; the aggregate HEAD^1..HEAD diff is what was verified.
  • Playwright e2e and visual suites (test:e2e, test:e2e:visuals) — browser-based, out of scope.
  • The rest of the integration suite (cli/interactive beyond the session-switching file).
  • Windows/macOS platform-specific behavior (container is Linux; unit tests run under jsdom).
  • DataWorks host-controller behavior, daemon prompt/cancel semantics beyond the session-switching suite, branch/fork navigation, legacy source visibility — declared out of scope by the PR and not exercisable here.
  • Finer single-point mutants beyond the six guard families (e.g. workspaceBecameKnown retry migration, plan-prompt generation check in isolation, shell-command preparation owner) — covered by the base-red cell set and in part by M1, but not individually mutated.
  • Base-side attribution of the act() warning in finding 1.

Methodology

Environment: CI verify container (node:22-bookworm), Node v22.23.2, npm ci + npm run build pre-run at merge ref 019664d1dc; npm run bundle re-run (exit 0) so the integration suite's dist/cli.js reflects the merge ref. A/B control built as git worktree add tmp/base-tree HEAD^1 with the HEAD test file copied in (sha256-identical) and packages/web-shell/node_modules symlinked (lockfile and every package.json untouched by the PR; readlink -f asserts internal workspace deps resolve to unchanged packages); mutations ran in tmp/head-mut (merge ref), applied by harness/apply-mutant.mjs (single-occurrence-anchored string replacements), each reverted before the next (git checkout verified clean). Suites: npx vitest run App.test.tsx --config vitest.config.ts (~32–40 s/run), the workspace-wide npx vitest run --config vitest.config.ts (181 files), and QWEN_SANDBOX=false npx vitest run cli/qwen-serve-webui-session-switching.test.ts from integration-tests/. Gates proven live before citing: planted TS2322 (tsc exit 2, named the planted file) and planted unused variable (ESLint exit 1, no-unused-vars), both removed after. Scripted harnesses: harness/ab-assert.mjs (9 checks), harness/matrix-assert.mjs (25 checks), harness/gates-assert.mjs (17 checks); evidence images via scripts/verify-capture.mjs. Assertion accounting: 9 vitest runs × 430 (head, base, control, M1–M6; base-arm reds counted as passes per the expected-failures rule encoded in ab-assert.mjs) + workspace suite 3333 + daemon suite 5 + harness checks 51 = 7259. Raw per-arm/per-mutant logs: logs/. Worktrees removed after capture.

Evidence images

01-ab-head-arm-430-green

02-ab-base-arm-37-red

03-mutation-matrix-six-mutants

04-ab-assert-flip-set

05-gates-assert

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

Qwen Code · sandboxed verification

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

Reviewed the current head (0ab4f16) against the prompt-safe navigation design contract — static functional review of App.tsx (+986/−160), cross-checked against the resolved R1 threads. Approving: no Critical/Important findings.

What I verified

  • Identity model: block object reference → persisted sourceRecordIds → turn-error promptId/eventId; reducer-local block ids are only trusted within the same live attachment (allowLocalId gated on snapshot.isCurrent()). This correctly closes the local-id-reuse hazard across transcript replacement.
  • Owner model: snapshot.isCurrent() is session-client instance identity, so same-id attachment replacement is caught; retryOwnerMatchesCurrent checks sessionId + sourceVersion + workspace/snapshot, with fail-closed drops for workspace-unknown retries whose owner is gone.
  • Side-effect ordering: lastSubmitted* refs, retryOwnerRef refresh, clearFollowup(), and composer commit/clear all moved after both gates (post admission-hook, post ensureSessionForPrompt) — stale continuations are genuinely side-effect free.
  • Retry retention: attemptId supersedes out-of-order settlements; per-logical-session restoration is gated on navigation settle + transcript not loading/catching-up; rehydration is restricted to a matching preceding identity or an empty transcript.
  • Preparation races: the symbol preparation-ownership token prevents concurrent preparations from clearing each other's isPreparingPrompt; the plan-mode .finally() + token guard cannot leave the preparing state stuck.
  • R1 Critical fixes present at this head: turn-error rearm via failedTurnErrorRetryRef/rearmFailedTurnErrorRetry, background_notification skip in getLatestUserBlock, local-id fallback for clone-on-write, and restoreOrDeferCancelledRetry on not-admitted failures.

Notes (non-blocking)

  • In the .catch handlers of handleFailedPromptRetry/handleRetry, the !retryOwnerIsCurrent early return skips restoreOrDeferCancelledRetry if admission rejects (rather than cancels) while the user has navigated away. Low likelihood, pre-existing semantics; the message body remains in the transcript, only the retry affordance is lost.
  • Deferred cancelledRetryStatesRef buckets (which may carry base64 images) persist for sessions never revisited until App unmount (discussed in R1-7; bounded by sessions visited × 2 kinds).

Verification note: my review was static (no local test/typecheck run); the PR's stated evidence is 422/422 App suite plus mutation checks, and the CI Linux suite is green on this head.

@doudouOUC
doudouOUC dismissed stale reviews from qwen-code-ci-bot and wenshao August 12, 2026 09:33

already have 2 approved,3ks

@doudouOUC
doudouOUC added this pull request to the merge queue Aug 12, 2026
Merged via the queue into QwenLM:main with commit 675dd45 Aug 12, 2026
513 of 532 checks passed
@doudouOUC
doudouOUC deleted the fix/8923-admission-owner-guards branch August 12, 2026 09:34

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.11.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants