fix(web-shell): keep mid-turn messages the daemon rejects at idle - #11289
fix(web-shell): keep mid-turn messages the daemon rejects at idle#11289wenshao wants to merge 35 commits into
Conversation
The daemon's idle verdict can reach the browser before its own streaming-state update, so a follow-up typed while a turn was ending was deleted from the local queue and surfaced as a rejected message even though nothing had failed. The rejection now states its reason, and the client uses that reason only to pick the fallback path: before echoing or dropping the row it confirms against the authoritative pending-prompt snapshot, and it removes a cleared row only when that snapshot still lists the prompt as queued, because removal otherwise aborts a turn the daemon is already running. A snapshot that never arrived proves nothing and is treated the same way. Goal-hold, write-block and owner-session guards are unchanged, and an ambiguous transport failure still never triggers a blind resend.
Verification report —
|
| Gate | Result |
|---|---|
npm run build |
exit 0 |
npm run bundle |
exit 0 |
npm run typecheck |
exit 0 |
packages/web-shell full suite |
279 files / 6420 tests passed |
packages/acp-bridge full suite |
35 files / 1959 tests passed |
packages/sdk-typescript daemon client |
415 tests passed |
packages/cli src/serve/server.test.ts |
1226 tests passed |
npx eslint / npx prettier --check on every changed file |
clean |
The four queued-prompt suites that carry this change: 223 tests passed.
Live daemon wire capture
A real serve process built from this commit (isolated QWEN_RUNTIME_DIR, random port, fresh thread session) refused three mid-turn inserts at idle — plain text, the same messageId repeated, and an image-only message. Each returned exactly {"accepted":false,"reason":"session_idle"} under a strict deep-equality assertion, and the mid-turn queue, the settled/promoted rings and the pending-prompt list were all empty afterwards, so the refusal transfers no ownership. The daemon logged rejected id "pr11289-idle-text": session idle and rejected id "pr11289-idle-image": session idle, then stopped cleanly on SIGTERM. Full transcript in the PR description.
The new tests are load-bearing (mutation-checked)
Each behaviour this PR adds was re-verified by breaking it and watching exactly one test go red:
| Mutation | Result |
|---|---|
| Removal guard inverted so an unavailable snapshot licenses removal | does not remove a cleared delayed fallback whose confirmation snapshot fails fails — removal issued once; the queued / running / started variants stay green |
| Image-only placeholder clause deleted from the matcher | binds an image-only idle fallback through the daemon placeholder text fails — message echoed and the queue row dropped |
| Duplicate-row filter deleted | drops the local duplicate when two identical idle fallbacks queue fails — 4 rows for 2 messages |
| Idle flag made unconditional instead of keyed on the stated reason | does not echo a reasonless rejection the daemon starts itself fails |
Before the last of those tests existed, that mutation left the whole suite green.
Review round
A balanced (medium) review round ran over this change: 10 finder dimensions, all 4 diff chunks covered, then 2 verification shards that ruled on every finding with executed evidence (probes and mutations, not readings). 16 findings — 3 Critical, 10 Suggestion, 3 Nice to have; 6 of them low confidence. 9 were fixed in this branch, including both high-confidence Criticals:
- the cleared-queue removal that could cancel a turn the daemon was already running when its confirmation snapshot failed or was superseded (now removal requires the snapshot to still list the prompt as queued);
- the protocol sentence that described
session_idleas a property of session state rather than of the refusal's cause, which would have led an integrator to read a missingreasonas "busy" or "old daemon".
Seven were skipped on the record, the two worth naming here:
- The daemon's pending-prompt removal is still unconditional. A removal decided from a snapshot that was accurate when read can in principle hit a prompt that started in the meantime. Closing that needs an opt-in conditional removal on the daemon side (bridge type, route and SDK), which is a separate change; this PR removes the client-side half by no longer removing on an unknown state. Worth a follow-up issue.
- Two blocks that discard an admitted prompt whose local row disappeared now exist side by side — the one this PR adds registers the id so a concurrent refresh cannot re-materialize the row, the pre-existing one does not. Unifying them changes the behaviour of a path this PR does not otherwise touch, so it was left alone; the pre-existing gap is transient (the next successful refresh drops a row the daemon no longer lists).
Not validated
- No browser was driven: the timing between the daemon's refusal and the browser's own streaming-state update is reproduced in the hook harness with stubbed SDK actions, not in a live Web Shell. No screenshots, no toast DOM, no real image decode.
- No two-client race was driven end to end, so an image-only follow-up competing with another client's image-only prompt in the same session is reasoned about rather than measured.
- The mutation-testing gate that would have measured hunk survival automatically returned
inconclusiveon every probe (its runner could not produce parseable JSON in the probe tree), so the mutation results above are hand-run. - Whole-repo
npm run lintaborts with a Node heap exhaustion on this machine (~4 GB); lint was run per changed file instead. - Windows and Linux untested locally — CI covers them.
packages/clihas 8 pre-existing failing test files on this machine (user-config and absolute-path leakage: provider list,~/.qwenpath comparisons, a timeout, status-code semantics). None is touched by this PR; the serve-routes suite that this PR does touch passes 1226/1226 in isolation.
中文摘要
以下全部在本 PR 提交 9add20f0ca 上、macOS 本地、对当前 main 执行干净 npm ci 之后运行;没有为了让结果变绿而修改任何源码或测试。
自动化门禁:npm run build、npm run bundle、npm run typecheck 均 exit 0;packages/web-shell 全套件 279 文件 / 6420 测试通过;packages/acp-bridge 35 文件 / 1959 通过;packages/sdk-typescript daemon client 415 通过;packages/cli 的 src/serve/server.test.ts 1226 通过;所有改动文件的 eslint 与 prettier --check 干净。承载本改动的 4 个队列测试文件共 223 个用例通过。
真实 daemon wire 抓取:用本提交构建的真实 serve 进程(独立 runtime 目录、随机端口、全新 thread 会话)在空闲态拒绝了三次 mid-turn 插入 —— 纯文本、同 messageId 重复、仅图片。三次都在严格深比较下等于 {"accepted":false,"reason":"session_idle"},随后 mid-turn 队列、settled/promoted 环与 pending-prompt 列表全为空,说明拒绝不转移所有权。daemon 记录了两条 rejected id …: session idle,并以 SIGTERM 干净停止。完整记录见 PR 描述。
新增测试均经变异验证:把守卫改成"快照不可用也允许删除"、删掉图片占位文本分支、删掉重复行过滤、把空闲标记改成无条件 —— 每次都恰好有一个对应用例变红,其余保持绿。其中最后一项在这个测试出现之前,整个套件对该变异是绿的。
审查轮次:balanced(medium)轮次覆盖全部 4 个 diff chunk、10 个 finder 维度,再由 2 个验证分片用可执行证据(探针与变异,而非阅读)逐条裁决,共 16 条发现(3 Critical / 10 Suggestion / 3 Nice to have,其中 6 条低置信)。本分支修掉 9 条,含两条高置信 Critical:一是确认快照失败或被顶替时清空队列仍会取消 daemon 已在运行的回合(现在必须快照仍列为 queued 才删除);二是协议文档把 session_idle 写成会话状态的属性而非拒绝原因,会让集成方把"缺少 reason"误读成"忙"或"老 daemon"。
7 条记录在案地跳过,其中两条值得单列:daemon 的 pending-prompt 删除仍是无条件的,彻底收口需要在 bridge 类型/路由/SDK 增加可选的条件删除,属另一个改动,本 PR 只去掉了客户端这一半;以及"丢弃已被接受但本地行已消失的提示词"现在有两处并存(本 PR 新增的那处会登记 id 以防并发刷新把行复活,既有那处不会),统一它会改变本 PR 未触及路径的行为,因此保留原状 —— 既有那处的漏洞是瞬态的。
未验证:没有驱动真实浏览器(拒绝与浏览器流式状态更新之间的时序是在 hook harness 中用桩复现的),无截图、无 toast DOM、无真实图片解码;没有端到端驱动双客户端竞态;自动化的 hunk 存活率门禁(test-efficacy)四个探针全部 inconclusive,因此上面的变异结果都是手工跑的;本机整仓 npm run lint 会 Node 堆溢出(约 4 GB),改为逐文件 lint;Windows / Linux 未在本地测试,交给 CI;packages/cli 在本机有 8 个既有失败文件(用户配置与绝对路径泄漏所致),均与本 PR 无关,本 PR 触及的 serve 路由套件单独运行 1226/1226 通过。
🩺 serve daemon A/BBuilt the PR base vs this PR head ✅ No response changes against the PR base across 12 scenario(s). — Qwen Code · serve A/B |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterℹ️ No screenshot changed against the PR base — but this PR edits 1 render-shaping file:
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 Full-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
|
Thanks for the PR! This is a re-run at Template looks good ✓ — every required heading is there, including the full Chinese translation. Problem: observed, not theoretical. No GitHub issue was filed ("reported internally"), but the evidence is stronger than most linked issues carry. The PR body has a wire-level before/after against a real Direction: aligned. A follow-up typed at the end of a turn silently vanishing, or a queue row that can be neither edited nor cancelled and later runs as a surprise, is user-visible data loss in the Web Shell. It does add a Size: cross-package (
Under the 1000-line advisory, so no split recommendation on size grounds. Approach: this is the one thing I'd push on before code review, and six rounds make it concrete rather than speculative. Every Critical from rounds 4–6 lives in the same new machinery — the confirmation snapshot and its verdict bookkeeping. There are now three verdict sites inside one function ( Risk: no elevated risk signals — none of the changed files match the high-risk path list. Moving on to code review. 🔍 中文说明感谢贡献!本次是在 模板完整 ✓ —— 所有必需标题都在,包含完整的中文翻译。 问题: 已观测到的缺陷,不是理论性加固。虽然没有创建 GitHub issue("内部反馈"),但证据比多数关联 issue 更扎实。PR 描述里有针对真实 方向: 对齐。回合末尾输入的追问无声消失,或队列行既不能编辑也不能取消、之后还会突然自己跑起来,这是 Web Shell 中用户可见的数据丢失。它确实给 规模: 跨包改动( 生产代码 618 行( 方案: 这是我在代码审查前唯一想推动的一点,而六轮评审让它变得具体而非臆测。第 4–6 轮的每一条 Critical 都落在同一套新机制里——确认快照及其判定记账。现在一个函数内有三个判定点( 风险: 无升级风险信号——改动文件均未命中高风险路径清单。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewMy independent proposal, written from the title and "Why it's needed" before reading the diff. Three moves. (1) Daemon: state the refusal reason on the wire — cheap, and the cross matrix proves it is load-bearing, so I'd do exactly what this PR does. (2) Client: on Comparing that to the diff: the PR matches me on (1) and is more ambitious on (2) and (3) — it keeps the client as the arbiter, using six refs across three verdict sites. It explicitly scopes (3) out ("the daemon's pending-prompt removal is still unconditional … this PR removes the client-side half"). That choice is the cost centre. The client is doing distributed-state inference against an owner of that state that it could simply ask, and six review rounds have each found a new ordering where the inference is wrong. My proposal is materially simpler on the axis that matters here, and I'd rather name that than let it surface as a seventh ref. Critical blockers — four, all verified by me at this headI read each of these against the file as it stands at
Two more from earlier rounds are still unresolved and unchanged in shape: R5-1 ( Where the maintainer's product-level run actually landsThe Linux browser run is genuinely good verification, and I want to be precise about which findings it touches rather than waving at it either way.
To the run's credit it says this itself: "honest negatives from a real driver, not proofs of absence … may still be reachable by an ordering I did not construct." That is the right framing, and it is why I'm not treating the merge-ready verdict as closing these. Non-blockingThe run's own observation 1 is worth acting on: sequenceDiagram
participant P1 as User
participant P2 as useQueuedPrompts hook
participant P3 as serve daemon
participant P4 as Queue panel and transcript
P1->>P2: send follow-up during a running turn
P2->>P3: POST mid-turn-message
P3-->>P2: 200 accepted false, reason session_idle
P2->>P2: requeue row flagged resubmittedAfterIdleRejection
P2->>P3: POST prompt (resubmit as ordinary)
P3-->>P2: 202 with promptId P
P2->>P3: GET pending-prompts (confirming snapshot)
P3-->>P2: snapshot listing P as queued or running
alt verdict site 1 at line 1702 - row already cleared
P2->>P3: DELETE pending-prompt P (R5-2 missing start veto)
else verdict site 2 at line 1779 - confirming GET failed
P2->>P4: echo message (R6-2 missing settle veto)
else verdict site 3 at line 1819 - confirming GET refreshed
P2->>P4: bind row to P as queued
end
Note over P2,P3: R6-1 - the wait path at line 899 can re-dispatch<br/>for an abandoned session and supersede the live one
Files changed (12)
Test evidenceThis is an unattended CI run, so I did not build or execute any PR-derived code — the evidence below is the PR's own CI, read through the API for the reviewed commit. All 96 check-runs on
Two coverage notes on the skips, since the PR's own "Tested on" table leans on local runs for the platforms CI did not exercise: the unit matrix ran on ubuntu / Node 22 only, and the CLI integration lane was skipped (the round-5 review disclosed the same). Cross-platform confidence therefore rests on the maintainer's local macOS and Linux runs, which are the author's claim, not evidence I re-ran. Not verified: R6-1, R5-2, R6-2 and R6-4 against the running product — no run, CI or local, has constructed those orderings. The Linux report covers the primary bug, the queue-row retention, and the no-cancelled-turn property, and it is explicit that the standing Criticals were not its target. Sandboxed verification would settle this: 中文说明代码审查我的独立方案(在读 diff 之前,仅依据标题与"为什么需要"写出)。 三步。(1) daemon 侧:在 wire 上说明拒绝原因——成本很低,且交叉矩阵证明它是必需的,这一点我会和本 PR 完全一致。(2) 客户端侧:收到 与 diff 对比:本 PR 在第 (1) 步与我一致,在 (2)(3) 上更激进——它让客户端充当仲裁者,用六个 ref 支撑三个判定点。它明确把 (3) 划到范围之外("daemon 的 pending-prompt 删除仍是无条件的……本 PR 去掉的是客户端这一半")。这个选择正是成本所在:客户端在对一个本可以直接询问的状态所有者做分布式状态推断,而六轮评审每轮都找到一种新的时序使该推断出错。在真正要紧的维度上,我的方案明显更简单——与其让它以"第七个 ref"的形式再冒出来,不如现在点明。 阻塞性 Critical —— 四条,均由我在本 head 上核实我是对着
另有两条早前轮次的 Critical 仍未决且形态未变:R5-1( 维护者的产品级验证实际覆盖到哪里Linux 浏览器验证 是很扎实的验证工作。我想精确说明它触及了哪些结论,而不是笼统地采信或否定。
需要说明的是,这份报告自己也讲清楚了:"这是来自真实驱动的诚实否定结论,不是不存在证明……仍可能存在我没有构造出的时序。" 这个表述是对的,也正因此我没有把"可以合入"的结论当作这些 finding 已关闭。 非阻塞报告自己的观察 1 值得处理: 测试证据本次为无人值守 CI 运行,因此我没有构建或执行任何 PR 派生代码——下面的证据是 PR 自己的 CI,通过 API 针对被审查提交读取。
关于跳过项有两点覆盖说明(PR 自己的 "Tested on" 表格在 CI 未覆盖的平台上依赖本地运行):单元矩阵只在 ubuntu / Node 22 上跑过,CLI 集成通道被跳过(第 5 轮评审也披露了同样情况)。因此跨平台信心来自维护者本地的 macOS 与 Linux 运行,那是作者的声明,不是我一并复跑的证据。 未验证:R6-1、R5-2、R6-2、R6-4 在运行态产品上的表现——无论 CI 还是本地,都没有构造出这些时序。Linux 报告覆盖的是主缺陷、队列行保留以及"不取消正在运行的回合"这一性质,并且明确说明未决 Critical 不是它的目标。 沙箱验证可以定论这一点: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 2/5 — the verification behind this PR is the best I've seen on a web-shell race, and the implementation still carries four correctness regressions I confirmed line by line at this head. Stepping back. The motivation is real and the evidence is exemplary: the failure reproduces on the merge base, disappears on this branch, has a negative control, and a 2×2 cross matrix proves both halves are load-bearing. That is how a race PR should be argued, and it is why I went into the code expecting to approve. I didn't, for a reason that has nothing to do with the evidence. The four Criticals in Stage 2 are not hardening or style — a session switch can leave the panel the user is looking at permanently unsynced, a recorded prompt start does not stop a DELETE that aborts a live turn, a failed confirming GET can echo the user's message twice, and a transport failure after admission tells the user their queue failed for a message already on screen and running. Each one is a wrong outcome the user can see, and each is a new surface this PR adds or a regression against the merge base. I read all four against The maintainer's Linux run does not close them, and to its credit it says so. It rebuts R5-1 convincingly — pre-existing on Am I being worn down by volume? I checked that, because six rounds invites it. No — I'd reach the same verdict on a first-round PR with these four findings, and the round-6 probes are measured A/B arms with tested fixes, not speculation. The pattern question is the one that matters here. Rounds 2 through 6 each closed a reported input and opened a new finding in the same cluster, which is exactly what the On splitting: I considered recommending the daemon half land separately, since it is 30 lines, additive, documented and independently proven necessary. But the cross matrix shows it fixes nothing alone, so splitting buys no user-visible progress — only a cleaner history. Not worth it here. Action: I'm not approving, and I'm not stacking a second One thing worth saying plainly to @wenshao: the residual-risk inventory your run built for R5-1 — second admission inside the window and every activity signal late, which in production is a stalled connection at that instant — is the right shape of disclosure, and it is what made accepting that rebuttal easy. The four above need the same treatment or a fix; what they can't survive is a negative from a driver that never entered their branch. 中文说明信心:2/5 —— 本 PR 背后的验证工作是我在 web-shell 竞态类改动中见过最扎实的,但实现仍然带有四条我在本 head 上逐行确认的正确性回归。 退一步看。动机是真实的,证据堪称典范:故障在 merge base 上复现、在本分支上消失、有反向对照,2×2 交叉矩阵证明两半都是必需的。竞态类 PR 就该这样论证,这也是我带着"准备批准"的预期去读代码的原因。 我没有批准,理由与证据无关。Stage 2 的四条 Critical 不是加固也不是风格问题——切换会话可能让用户正在看的面板永久不同步;已记录过的 prompt 启动并不能阻止一次会中止正在运行回合的 DELETE;确认 GET 失败可能把用户消息回显两次;接纳之后的传输失败会对一条已在屏幕上且正在运行的消息报出"入队失败"。每一条都是用户可见的错误结果,而且每一条都是本 PR 新增的表面或相对 merge base 的回归。我是对着 维护者的 Linux 验证没有关闭它们,而且报告自己也这么说。它对 R5-1 的反驳是可信的——在 我是否被数量磨软了?我检查过这一点,因为六轮评审容易如此。答案是没有——即使这是第一轮 PR、带着这四条 finding,我也会给出同样结论;而第 6 轮的探针是带实测修复的 A/B 双臂测量,不是臆测。 真正关键的是"模式"问题。第 2 到第 6 轮每次都关闭了一个已报告的输入,又在同一个簇里打开了新 finding,这正是 关于拆分:我考虑过建议 daemon 那一半单独合入,因为它只有 30 行、增量式、有文档、且被独立证明是必需的。但交叉矩阵显示它单独修不好任何问题,所以拆分带不来用户可见的进展,只换来更干净的历史。在这里不值得。 动作: 我不批准,也不会再叠加第二条 有一点想对 @wenshao 直说:你为 R5-1 建立的残余风险清单——窗口内出现第二次接纳且所有活跃度信号同时迟到,在生产中对应那一瞬间连接卡顿——正是恰当的披露形式,也是我能轻松接受那条反驳的原因。上面四条需要同样的处理,或者被修复;它们无法承受的,是一个从未进入其分支的驱动所给出的否定结论。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
@qwen-code /takeover from 2 |
|
🤝 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 window's round counter starts at 2 (the rounds this PR spent in review before takeover), so the Critical-only brake engages after 3 more change-producing round(s) instead of a full fresh 5. Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本窗口轮次计数从 2 起算(即本 PR 托管前已进行的评审轮数),因此再经过 3 个产生改动的轮次即进入 Critical-only,而非重新计满 5 轮。移除 |
Address review round 1: - R1-1 (critical): when a resubmitted-after-idle prompt cannot be bound by the confirming sync (identical texts carrying attachments suppress both the text binding and the materialization), bind the local row by the authoritative promptId the daemon returned as long as the snapshot still lists it queued, instead of falling through to echo the message and drop a row the daemon still holds. - R1-14: drop the row the confirming sync materializes for a prompt the user already cleared before issuing the DELETE, so the cleared message is not resurrected for the whole removal round trip. - R1-6: remove the retention-filter guard the synchronous drop makes unreachable; mid-removal resurrection stays suppressed by the pre-existing sync-loop guard, now pinned by a mutation-verified test. - R1-7: pin the session_idle reason forwarding across sessionActions.enqueueMidTurnMessage so a narrowing at that seam fails a test instead of shipping silently.
|
🤖 Addressed the latest review feedback (round 3/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 3/100 轮)。改动内容与我反驳保留之处如下: Review round addressed —
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit — stopped before round 8 by the review time budget.
Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:
packages/web-shell/client/hooks/useQueuedPrompts.ts:401 — [review] D2-1 two of the three new matchesUnboundSubmittingRow clauses (the '' to '[image]' placeholder match and the files relaxation) are not discriminated by any test; reverting e…packages/web-shell/client/hooks/useQueuedPrompts.ts:1522 — [review] D2-2 the displayedServerPromptIdsRef disjunct that stops the client DELETEing a prompt the daemon already started has no test; deleting it leaves 197/197 greenpackages/web-shell/client/hooks/useQueuedPrompts.ts:1581 — [review] D2-3 neither disjunct of startedOrCompleted is discriminated by any test, and completedPromptIdsRef is the only echo path when a turn completes before the snapshot settlespackages/web-shell/client/hooks/useQueuedPrompts.ts:1552 — [review] D2-4 both failure branches of the new confirmation DELETE (removed:false and the rejection handler) are untested at this call site, and they are the only recovery for a row…packages/web-shell/client/hooks/useQueuedPrompts.ts:2031 — [review] D2-5 the new prompt.id !== restoreAdmission.id deletion term is load-bearing (a probe shows duplicateIds [1] without it) but unpinned: removing it leaves 197/197 greenpackages/web-shell/client/hooks/useQueuedPrompts.ts:1552 — [review] D2-6 the two DELETE failure arms never touch prompt.onComplete although removed:false means the prompt was not cancelled, unlike every sibling path in the hook
中文说明
仅完成部分审查,审查缺口已披露。
未审查:反向审计——评审时间预算不足,未能开始第 8 轮。
收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 6 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.23.0)
…d state Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
🤖 Addressed the latest review feedback (round 4/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 4/100 轮)。改动内容与我反驳保留之处如下: Autofix round — PR #11289All five findings from the round-2 review are addressed in Feedback pointsR2-1 (Critical, rc:3954525496) — text fallback can bind a co-client's prompt — FIXED
R2-2 (Critical, rc:3954525509) — sync-spliced row misread as a user clear — FIXEDThe branch now discriminates on the row's own provenance instead of its mere absence. R2-3 (Critical, rc:3954525517) — superseded confirmation forfeits the DELETE — FIXEDThe submit body now re-awaits until it holds a non-superseded result: R1-14 (Suggestion, rc:3954525523) — removal re-entry races a user delete/edit — FIXEDBoth halves of the guard are in place. (1) The branch returns early (settling R2-4 (Suggestion, rc:3954525532) — id-binding ignores settled/removing markers — FIXEDThe id-binding branch now computes Notes
Verification
中文说明Autofix 本轮处理 — PR #11289第 2 轮评审的全部 5 条发现已在 反馈条目R2-1(Critical,rc:3954525496)—— 文本回退可能绑到同会话其他客户端的 prompt —— 已修复
R2-2(Critical,rc:3954525509)—— 被 sync 摘除的行被误读为用户清空 —— 已修复该分支现在依据行自身的来源做区分,而不再仅凭"行不存在"。 R2-3(Critical,rc:3954525517)—— 被取代的确认快照使 DELETE 落空 —— 已修复提交体现在会重新等待,直到拿到非 superseded 的结果: R1-14(Suggestion,rc:3954525523)—— 删除重入与用户的删除/编辑竞争 —— 已修复守卫的两半都已落地。(1) 当 R2-4(Suggestion,rc:3954525532)—— 按 id 绑定不看 settled/removing 标记 —— 已修复按 id 绑定分支现在用 说明
验证
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧵 Resolved all 5 selected review thread(s). · 已关闭全部选中的 5 条评审线程。 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/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- the untested
fileshalf of the matcher relaxation at packages/web-shell/client/hooks/useQueuedPrompts.ts:403 — already reported (round-2 review 5137546373, deferral D2-1)
Not reviewed: test-efficacy probe — harnessValidated: null, all 5 probes inconclusive (reason: no-output; the probe's per-file runner tripped scripts/vitest-global-setup.js), mutants probed 0 and hunks probed 0, so no mutation or hunk-necessity measurement exists for this PR either way.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": did not trace every caller of recoverAfterSettledInsert or the daemon's session_idle rejection path end-to-end to prove a displayed, same-rendered-text prom…; "agent reverse-audit (round 1)": did not run the web-shell vitest suite or npm run typecheck to confirm the discriminated-union change compiles at every consumer; the read-site walk above is …; chunk 1: did not execute packages/acp-bridge/src/bridge.test.ts or packages/cli/src/serve/server.test.ts ; I substituted the site-by-site read described above, which …; "agent reverse-audit (round 2)": did not settle whether entry.promptActive can be true while pendingPromptCount === 0 — the mid-turn idle gate at bridge.ts:13517 is the only busy check in t…; "agent reverse-audit (round 2)": ran no test suite — the "no other exact-shape { accepted: false } assertion regressed" conclusion comes from reading every such site in bridge.test.ts, server…, and 3 more.
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:
packages/web-shell/client/hooks/useQueuedPrompts.ts:1522 — [probe] the daemon confirmation that is supposed to replace the client's own view of the queue is itself gated on that view (latestSessionActiveRef.current)packages/web-shell/client/hooks/useQueuedPrompts.ts:382 — [probe] the '[image]' coupling to another package's renderer is unpinned both ways: the eponymous placeholder test cannot detect removal of the clause, and the literal is a private c…packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:2109 — [probe] two getPendingPrompts once-mocks assign one resolver, so the test can go vacuous silently if the mount reconcile's call count shifts by onedocs/developers/qwen-serve-protocol.md:2729 — [probe] the documented resubmit remedy is refused 409 worktree_reset_active while a reset barrier is armed, yet the mid-turn route still answers session_idle in that same windowpackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:2280 — [probe] no test in the package observes the diff's change to the pending_prompt_started matcher; restoring the pre-diff predicate leaves 6675 tests byte-ident…packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:1850 — [probe] the sync's uniqueness barrier is unpinned (relaxing it to >= 1 leaves the suite identical) and the divergence is reachable and was observedpackages/web-shell/client/hooks/useQueuedPrompts.ts:386 — [review] the change falsifies the committed design record at docs/design/web-shell/web-shell-image-drag-and-drop.md:217-221 and :576-579, which the PR does not updatepackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:2088 — [probe] submits an explicit insert rejected because the session became idle gets its echo from the absent-from-snapshot fall-through, so it reads as a regress…packages/web-shell/client/hooks/useQueuedPrompts.ts:392 — [review] the added JSDoc justifies the attachment waiver with 'the daemon's summary loses them', which the same sync iteration contradicts by deriving serverImages/serverFiles from t…packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:2310 — [probe] does not remove a delayed idle fallback that started before its snapshot stays green under both relaxations its own name forbids
Convergence: round 3 posted 8 inline comment(s), 7 of them reported for the first time; the previous round posted 5 (5 new). Findings keep coming back to the same files: packages/web-shell/client/hooks/useQueuedPrompts.ts (findings in rounds 1, 2; 4 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查(原文为英文):test-efficacy probe — harnessValidated: null, all 5 probes inconclusive (reason: no-output; the probe's per-file runner tripped scripts/vitest-global-setup.js), mutants probed 0 and hunks probed 0, so no mutation or hunk-necessity measurement exists for this PR either way.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)":did not trace every caller of recoverAfterSettledInsert or the daemon's session_idle rejection path end-to-end to prove a displayed, same-rendered-text prom…;"agent reverse-audit (round 1)":did not run the web-shell vitest suite or npm run typecheck to confirm the discriminated-union change compiles at every consumer; the read-site walk above is …;chunk 1:did not execute packages/acp-bridge/src/bridge.test.ts or packages/cli/src/serve/server.test.ts ; I substituted the site-by-site read described above, which …;"agent reverse-audit (round 2)":did not settle whether entry.promptActive can be true while pendingPromptCount === 0 — the mid-turn idle gate at bridge.ts:13517 is the only busy check in t…;"agent reverse-audit (round 2)":ran no test suite — the "no other exact-shape { accepted: false } assertion regressed" conclusion comes from reading every such site in bridge.test.ts, server…,另有 3 条。
未审查:反向审计——在 5 轮的反审轮数上限内未收敛。
收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 10 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 3 轮发布了 8 条行内评论,其中 7 条是首次提出;上一轮发布了 5 条(其中 5 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/hooks/useQueuedPrompts.ts(第 1、2 轮已出过发现,本轮又有 4 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.23.0)
A rendered prompt text is not an identity: the daemon renders every text-less prompt as '[image]' (or ''), so binding a submitting row on that string could echo, claim, or cancel a prompt the row does not own. Attachment rows now claim a server prompt only through a guarded placeholder route — own-client originator, text-less rendering on both sides, identical hydrated image payloads, and uniqueness of the match on both the row and the server side — while plain text rows keep the fail-open exact-text route. Ambiguity at the started event degrades to no echo instead of binding the first match. Pending-prompt refreshes are now single-flight per session: concurrent callers share the in-flight snapshot instead of superseding each other's sequence numbers, which two concurrent submit confirmations could otherwise do forever. The unbounded re-await loop in the submit body is removed with it. Also: materialize provably foreign queued prompts instead of suppressing them behind an unbound attachment row; record sync claims only for resubmitted rows (the only readers) and reset the record with the other per-owner collections; pin the settled-prompt term in the removal licence and the reportError silence of the failed-confirmation branch.
|
🤖 Addressed the latest review feedback (round 5/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 5/100 轮)。改动内容与我反驳保留之处如下: Address-review round — PR #11289Growth audit: Findings
No maintainer-decision items and no deferrals this round; the deferred non-Critical section and the review body's recorded probes were left untouched as instructed. No base-conflict resolution was requested. Mutation probes (applied, measured, reverted)
Verification
中文说明评审处理轮次 —— PR #11289增长审计结论: 发现处理
本轮无需维护者决定的事项,也没有延后项;按指示未触碰"延后的非 Critical"段落与评审正文记录的探针。未请求解决基线冲突。 变异探针(均已施加、实测、还原)
验证
Deferred non-Critical feedbackCritical-only mode is active: the PR's diff grew src 112 / test 702 net lines beyond this counting window's baseline (budgets: 400/400). The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback continues to flow unaffected during a growth-only engagement (the per-author batch budget applies only after 5 change-producing rounds). ( 中文说明已进入仅处理 Critical 的模式:本计数窗口内 diff 净增长已达 源码 112 / 测试 702 行(预算 400/400)。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。纯增长触发期间维护者反馈照常流动(按作者的批次预算仅在完成 5 个产生改动的轮次后生效)。(评论 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧵 Resolved all 8 selected review thread(s). · 已关闭全部选中的 8 条评审线程。 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/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
6 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- R3-3
keeps a row whose owning action is settling when the confirmation drops rows(useQueuedPrompts.midTurnReconcile.test.tsx:3303) — already reported at useQueuedPrompts.midTurnReconcile.test.tsx:3406 (comment 3958629796); overlap drop, … prompt.id !== restoreAdmission.idfilter term (useQueuedPrompts.ts:2202) — already reported (round-2 review 5137546373, deferral D2-5)startedOrCompletedecho path (useQueuedPrompts.ts:1734-1746) — already reported (round-2 review 5137546373, deferral D2-3)binds an image-only idle fallback through the daemon placeholder text(useQueuedPrompts.midTurnReconcile.test.tsx:1661) — already reported (round-3 review 5142624814, deferral at useQueuedPrompts.ts:382)- the new DELETE arm's
prompt.onCompleteomission (useQueuedPrompts.ts:1676-1710) — already reported (round-2 review 5137546373, deferral D2-6) - matcher clauses at useQueuedPrompts.ts:423 and useQueuedPrompts.ts:424-426 — already reported (round-2 review 5137546373, deferral D2-1; round-3 review 5142624814, deferral at useQueuedPrompts.ts:382)
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Not reviewed: test-efficacy probe — harnessValidated: null, all 5 probes inconclusive (reason: no-output; the probe's per-file runner tripped scripts/vitest-global-setup.js), mutants probed 0 and hunks probed 0, so no mutation or hunk-necessity measurement exists for this PR from that channel; every mutation number in this review was run by hand instead.
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): "agent reverse-audit (round 3)": an empirical probe of the unflagged-arm Critical (a scratch test replaying ordinary-submit → started event → refresh splice → parked POST response, plus a mutat….
2 Suggestion(s) were drafted inline past the resolved critical posting floor — the floor engaged early: the first-time-finding rate has not fallen for 2 consecutive round(s); the CLI moved them into the deferral list below (floor enforcement).
Deferred under the convergence posture (round 4, not a blocker) — the floor engaged early: the first-time-finding rate has not fallen for 2 consecutive round(s) — recorded, not requested in this round:
packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:3150 — [review] D4-1: The name and this comment describe a mechanism the same commit deleted. removes a cleared idle fallback even when an overlapping refresh super…packages/web-shell/client/hooks/useQueuedPrompts.ts:441 — [review] D4-2: The media/files identity barrier this commit exists to establish has no negative control — three of its refusal clauses can each be deleted with the entire package sti…
Convergence: round 4 posted 6 inline comment(s), 6 of them reported for the first time; the previous round posted 8 (7 new). Findings keep coming back to the same files: packages/web-shell/client/hooks/useQueuedPrompts.ts (findings in rounds 2, 3; 5 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 6 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查(原文为英文):reverse audit — did not converge within the reverse-audit round cap of 5.
未审查(原文为英文):test-efficacy probe — harnessValidated: null, all 5 probes inconclusive (reason: no-output; the probe's per-file runner tripped scripts/vitest-global-setup.js), mutants probed 0 and hunks probed 0, so no mutation or hunk-necessity measurement exists for this PR from that channel; every mutation number in this review was run by hand instead.
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)":an empirical probe of the unflagged-arm Critical (a scratch test replaying ordinary-submit → started event → refresh splice → parked POST response, plus a mutat…。
2 条 Suggestion 在已解析的 critical 发布下限之外被起草为行内评论——发布下限因首次发现速率连续 2 轮未下降而提前生效;CLI 已将其移入下方延后清单(下限强制执行)。
收敛姿态下延后(第 4 轮,非阻断)——发布下限因首次发现速率连续 2 轮未下降而提前生效——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 4 轮发布了 6 条行内评论,其中 6 条是首次提出;上一轮发布了 8 条(其中 7 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/hooks/useQueuedPrompts.ts(第 2、3 轮已出过发现,本轮又有 5 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
— qwen3.8-max via Qwen Code /review (v0.23.0)
…binds Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下: Autofix round summary — PR #11289Addressed all six Critical findings from the round-4 review (plus verified the earlier-round findings still hold). One commit: Round-4 findings (all Critical, all fixed)
Earlier-round findingsAll findings from rounds 1–3 were re-verified as still fixed at HEAD (code + their pinning tests: No merge conflicts ( Mutation probesEvery guard/branch added this round was verified by removing it and watching its witness fail:
All probes were reverted and the suites re-run to green. Verification
中文说明Autofix 本轮总结 — PR #11289处理了第 4 轮评审的全部 6 个 Critical 发现(并复核了前几轮发现仍然成立)。共一个提交: 第 4 轮发现(均为 Critical,均已修复)
前几轮发现第 1–3 轮的全部发现均已复核在 HEAD 上仍然修复(代码 + 各自锁定测试,包括: 无合并冲突( 变异探针本轮新增的每个守卫/分支都通过"移除后观察见证测试失败"验证:
所有探针均已还原,套件重新跑绿。 验证
Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧵 Resolved all 23 selected review thread(s). · 已关闭全部选中的 23 条评审线程。 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/模型 |
…precisely The conditional-close clause described a promotion that cannot succeed: any attempt during the confirmation window dies at sendPrompt's authorizing-close gate, so a session closing or authorizing a close never promotes. Also drop the last two redundant refresh fence arguments, strengthen the files-binding fixture so the categorical refusal is what it pins, and correct two stale comments restating premises this PR's own fixes falsified.
- a resubmission whose ambiguous start settled inside its confirmation dropped its row in silence: the settle defers to the in-flight body and the body's settled arms never echoed — consume the surviving park first - the settle-time park consume echoed an empty bubble for a prompt the daemon rendered as '' with no payload source — refuse the empty text - a stale snapshot overruled by the deferred-clear's start marker still re-materialized the user-cancelled message as a queue row — keep the overruled id out of that pass's sync Also delete the unreachable single-flight fast-join branch, clear the in-flight ref by dispatch sequence instead of promise identity (no TDZ on a synchronous throw), restore four mutation-shadowed test pins, and correct comments and test names that described mechanisms the code does not use.
…-unbound clears Two executed holes around the idx === -1 discard arm: it DELETEd a prompt whose bound row the started event had already echoed and dropped (aborting a live turn), and it never registered the removal, so a start landing inside the DELETE flight echoed a message the user had cleared instead of parking. License the arm with the client-side start/settle markers and restructure its DELETE chain to the sibling shape. A user clear of a row whose body returned unbound also lost the only record of its daemon id — hand it to the deferred clear so the next snapshot cancels it. Also delete the removal park on every confirmed removal (the retained stash outlived the prompt and flipped a cancelled turn's completion memory), stop leaking an unprunable echo marker for bodies that already returned, drop a provably dead bind term and a duplicate refresh, document the attachment reference decline that answers before any mid-turn verdict, and correct six more comments stating premises the code or the daemon contract contradict.
Four executed holes from the audit battery: - a stale pending-prompts flight the single-flight fence waits out still applied its own sync, and its pre-admission snapshot dropped the row a submit body had just bound by id — stamp each binding with the refresh sequence and exempt rows bound at or after the syncing flight's dispatch - the claim arm of the discard path returned without stashing, so a first-time resend whose row the sync spliced for a displayed twin was materialized summary-only and its start could never echo — stash the body's payload copy under the daemon id - the clear-queue handoff for a returned-unbound row deleted the very record the settle-time echo exemption reads — keep it; only the deferred-clear set gains the id - the attachment-decline contract sentence overclaimed its ordering — rescope it to new admissions and name the retry-ack and closing gates that answer first, in the doc, route comment and SDK JSDoc alike Also delete the echo the settle-park consume superseded (measured dead), add the missing workspaceCwd arm to the idle-rejection guard matrix, and correct two declaration comments describing routes the code contradicts.
The resubmission branch's claim arm returned without stashing, so a text-only message whose row a stale sync spliced for a displayed twin was re-materialized summary-only and its start could never echo: the daemon ran the message and the transcript never showed it. Mirror the discard arm and stash the body's payload copy under the daemon id. Also from the same audit round: - the close-time promotion sentence claimed a session authorizing a close never promotes what remains queued, but both settle guards test only `closing` — inside the conditional-close window a settling turn splices the queue and the admission gate refuses it, so those messages are dropped and land in no ring. Say that, and name the re-attach reconciliation a client then owes. - the snapshot matcher's server-file term was unreachable, the identical predicate six lines above already refusing a resource block: delete it, and reshape the fixture that claimed to pin the row-side files refusal so that refusal is the only one left standing. - no test reached the echo helper's files term, every files witness carrying a caption: empty one, so a caption-less file message is pinned rather than silently refused at all eight echo routes. - add the missing witness for the attachment decline running before the idle verdict, which the doc, the route comment and the SDK JSDoc state. - correct three comments whose invariants the code contradicts: the single-flight ref's "at most one GET per session", and the boundAtSeq retention ordering in both places that describe it.
The clear-queue handoff records a returned-unbound row's daemon id for the
deferred clear, but the clear path never asked for the snapshot that is the
record's only consumer. In a quiet session the cancellation was never
attempted: the first snapshot to arrive was the one the daemon's own
promotion triggered, by which time it reported the prompt running, so the
message the user cleared ran and was echoed into the transcript. Request
the evidence the handoff depends on, scoped to a clear that actually handed
one over — the pass cannot resurrect the dropped row, since the loop marks
the id as being removed before it deletes.
Also from the same audit round:
- drop the two boundAtSeq stamps the sync itself wrote: a stamp taken during
a pass can never satisfy a later pass's retention exemption, and one of
them overwrote the body-written value the field documents
- pin the two matcher gates that had no witness — a captioned prompt is a
different message than a blank row's placeholder rendering, and a
one-image row does not own a two-image payload
- assert the bound row's identity in the two-body stale-flight test, which a
rematerialized replacement also satisfied, and rename it off the fast-join
branch that no longer exists
- make the deferred-clear failure test model a reachable daemon state: a
rejected DELETE, not `{ removed: false }` followed by a snapshot that
still lists the prompt queued
- give the mid-turn retry-ack test a genuinely dead attachment reference, so
the ring-before-validation ordering the protocol documents can fail
- correct the comments whose stated mechanism the code contradicts: what a
submit link spans, what the retention exemption assumes about dispatch
order, and which path echoes a cleared image fallback
Two executed holes from the audit battery: - when two identical idle fallbacks queue, the sync materializes one row per daemon prompt and each submit body drops its own duplicate. The surviving row is summary-only and can never echo, and this arm stashed nothing, so both messages ran and were answered with neither reaching the transcript. Stash the body's payload copy under the daemon id, as the claim and discard arms already do. - the settle-time last-chance consume echoed the daemon's raw rendering whenever it was non-empty. For an attachment submission whose start parked, whose row the user then cleared, and whose body exited through the catch path, that rendering is the literal placeholder — so the transcript gained an '[image]' bubble for a message the user had just cleared. Refuse the placeholder beside the empty rendering, matching the started handler's rule that silence beats a placeholder. Also from the same round: - re-point the two removal witnesses whose fixtures modelled a state the daemon cannot produce — a not-removed answer followed by that same prompt still queued, or still starting. A lost DELETE can leave the prompt there, and it drives the same failure arm. - extract the echo-payload predicate that three sites inlined into one function, so a fourth payload kind cannot be added to some copies only - correct the comments whose stated mechanism the code contradicts: which arms replay a parked start, which payload rows can still bind, how many sites stash a payload, and what a not-removed answer can mean
The deferred-clear loop weighed five kinds of client-side evidence against a snapshot before deleting a prompt the user had cleared, but not the sixth: a removal this client already has in flight. A second DELETE answers not-removed, and both chains clear the one shared in-flight flag in their finally blocks, so the owning flight loses it while still running — a start arriving in that window is not parked, gets echoed from the bound row, and the in-flight DELETE then aborts the turn it just echoed. Add the term. Also from the same round: - delete a displayed-marker write in the settle-time consume that the block's own unconditional delete four lines later always undid, and which read as deliberate dedupe only because its visual twin in the removal replay really does survive - restore the release-chain comments to the admission-only settle they describe: no chain link can carry the resubmission flag, since both sites that set it submit directly, so the extra snapshot wait the previous reword claimed cannot occur - name both causes at the deferred-clear handoff, whose condition also fires when a snapshot arrived and simply no longer lists the prompt - reword the assertion comment a fixture re-point left contradicting its own test, and fix an error-code spelling that grep could not find
Audit follow-ups deliberately not in this PRThis branch went through 24 rounds of a reverse-audit battery (18-20 independent auditors per round over the whole diff, each round's findings recorded and dispositioned). Everything that was a correctness defect, and everything whose fix was cheap and local, is in the PR. What follows is the residue: items whose fix needs new machinery or a new fixture, recorded here so none of them is silently dropped. Each was traced to code, not guessed. Echo fidelity
Coverage
Robustness
One rejected fix, recorded so it is not re-proposed Dispatching a refresh from the resubmission branch's claim arm — to restore a queue row for an admitted prompt the claiming snapshot did not list — was tried and reverted: the extra snapshot is taken while the daemon may still not list the prompt, and it drops the row the sync just materialized (a sync-materialized row carries no bind sequence, so the retention filter cannot exempt it). Two existing pins reddened. The window is real but benign — the started event's own refresh restores the row, and the echo is already guaranteed by the payload stash — and closing it properly needs a stamp or a fence on the materialized row, not an extra GET. |
doudouOUC
left a comment
There was a problem hiding this comment.
Agent-assisted review at acc327275923239b87744740746d23471c0487c9 — 1 confirmed standing Critical; Partial review — coverage gaps.
Pinned base: ae78d5b800a275b526d3e707ce3112926d34b156. Read the complete current production diff, surrounding admission/requeue/drain/confirmation/echo logic, daemon and SDK forwarding, and selected regression tests. The 11,000+ added test lines and every historical race have not been exhaustively audited.
Critical — R7-1 held-row entrance still reaches premature echo
Rechecked thread 3977665713, including the author's refutation 3987100054, against this head. The unheld path is improved, but the held path still has this reachable ordering:
- A mid-turn request is in flight when a hold/write block activates; its response is
{accepted:false, reason:'session_idle'}.packages/web-shell/client/hooks/useQueuedPrompts.ts:2905-2925keeps a local row withoutresubmittedAfterIdleRejection. - Lift the hold while the client activity mirror reads idle. The release effect (
:3178-3225) stamps onlyserverState:'submitting';releaseChainedPromptpasses that unflagged row to ordinary admission (:2617). Another client's prompt can meanwhile occupy the daemon FIFO before this POST, without its SSE activity update having reached this browser. - Ordinary admission returns HTTP 202 before execution (
packages/cli/src/serve/routes/session.ts:7037-7074;packages/sdk-typescript/src/daemon/DaemonSessionClient.ts:667-686explicitly implements admission-only). With no start event and the mirror still idle, the row skips confirmation atuseQueuedPrompts.ts:2027, then:2382-2397echoes it as sent and removes it locally, although it is still queued. - The final refresh does not repair the queue affordance: the echo sets the displayed-ID marker (
:909-922), and snapshot reconciliation skips an already-displayed prompt (:765-767,870-871). The message remains scheduled but its editable/cancellable queue row is gone.
The refutation's statement that the ordinary tail binds the row is true only when the activity mirror is active; that is precisely the condition not guaranteed here. A confirmation dispatched after the released row's own admission would be fresh evidence about that admission, not reuse of the earlier idle verdict. Keep the fix scoped to this provenance/ownership gap rather than stamping every held row indiscriminately.
Add the hold→idle rejection→release case with a delayed activity stream, an admission-only response, and the returned prompt still queued; require no sent echo and a retained daemon-ID-bound queue row. The existing hold-change test (useQueuedPrompts.midTurnReconcile.test.tsx:3155-3207) stops before release, so it does not adjudicate this outcome. This is a static producer/consumer proof, not a locally executed reproduction.
Prior fixes and coverage
The previously reported freshness and deferred-redispatch guards remain present (useQueuedPrompts.ts:1078-1101,1224-1242); immediate idle-rejected submissions no longer gate confirmation on the activity mirror (:2027). The current matcher rejects content-less attachment binding and degraded/unhydrated media. The new boundAtSeq producer/read sites were traced, and its stale-flight regression body was read. These checks do not certify every older Critical as fixed.
The reason field survives bridge → owner-runtime route → SDK JSON result → session-actions forwarding; read the new actions-hop test. It is emitted only at the open-session/no-prompt-FIFO/no-Goal idle gate; closing, budget and mismatched-ID failures remain reasonless, and attachment validation can throw before the idle verdict. The route is live-session-owner routed, retaining the existing single-runtime compatibility case rather than introducing a new selected-workspace fallback.
Remaining gaps: exhaustive old Critical adjudication, every new echo/removal/settle interleaving and callback lifetime, all changed tests, and live browser behavior. Suggestions remain deferred after the existing rounds. No PR code, tests, builds, installs, or workflows executed. Maintainer status not independently established; bugfix size is advisory, not a refactor policy block. Comment only; no approval implied.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
5 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- packages/web-shell/client/hooks/useQueuedPrompts.ts:1078 — already reported (round-7 review, deferral D7-1)
- packages/acp-bridge/src/bridge.ts:13838 — already reported (round-5 review 5149861869, deferral D5-5)
- packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:1272 — already reported (round-6 review, deferral D6-7)
- packages/web-shell/client/hooks/useQueuedPrompts.ts:433 — already reported (round-7 review, deferral D7-3; itself a re-report of round-5 deferral D5-3)
- packages/web-shell/client/hooks/useQueuedPrompts.ts:2027 — already reported (round-7 review, deferral D7-6)
Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": did not execute the three new/changed bridge tests ( npx vitest run src/bridge.test.ts in packages/acp-bridge) — traced each against the harness and the close-…; "agent 1c": the middle ~12,700 lines of the useQueuedPrompts.midTurnReconcile.test.tsx diff (diff lines ~1955-14657) were not read line-by-line — I covered its head (348-…; "agent reverse-audit (round 3)": mutation-measuring (vitest run) whether the it.each arms' echo and refresh-count assertions are individually discriminating — settled by reading the productio…; "agent reverse-audit (round 2)": I did not enumerate every caller of syncServerQueuedPrompts to confirm the latest.seq > notBefore fence covers each apply, so I could not settle whether the….
Not reviewed: reverse audit — stopped before round 4 by the review time budget.
Deferred under the convergence posture (round 8, not a blocker) — recorded, not requested in this round:
packages/web-shell/client/hooks/useQueuedPrompts.ts:2034 — [review] The bounded-map stash-and-evict loop is hand-rolled at fivpackages/web-shell/client/hooks/useQueuedPrompts.ts:2459 — [review] The new cancel-after-submit chain is a near-copy of the repackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:1896 — [review] The 12-line 'another client's prompt started' event blockpackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:4636 — [review] Five of the six client-side-evidence clauses in the deferrpackages/web-shell/client/hooks/useQueuedPrompts.ts:1035 — [review] The settle keeps the stashed attachment payload alive whenpackages/web-shell/client/hooks/useQueuedPrompts.ts:3797 — [review] The new reason:'session_idle' taxonomy is folded into submpackages/web-shell/client/hooks/useQueuedPrompts.ts:4009 — [review] The new clear-all handoff only covers daemon ids recordedpackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:1553 — [review] The new it.each media arm is the one fallback arm that endpackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:2123 — [review] The new attachment matcher's 'a captioned row cannot own apackages/web-shell/client/hooks/useQueuedPrompts.ts:948 — [review] The displayed guard in the PR's new replayStartedDuringRempackages/web-shell/client/hooks/useQueuedPrompts.ts:829 — [review] The new server-side uniqueness count is taken over the RAWdocs/developers/qwen-serve-protocol.md:2786 — [review] Of the three precedence rules this paragraph adds about thdocs/developers/qwen-serve-protocol.md:2786 — [review] The added authorizing-close sentence may describe a path t
Convergence: round 8 posted 2 inline comment(s), 2 of them reported for the first time; the previous round posted 1 (1 new). Findings keep coming back to the same files: packages/web-shell/client/hooks/useQueuedPrompts.ts (findings in round 7; 2 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)
Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (2 Critical(s)), the rate of first-time findings is not falling (this round 2, previous 1), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):
| standing Critical | attack surface | attacker-dependency | blast radius |
|---|---|---|---|
| (each standing Critical) | … | … | … |
Advisory only — it does not block this review.
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 5 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)":did not execute the three new/changed bridge tests ( npx vitest run src/bridge.test.ts in packages/acp-bridge) — traced each against the harness and the close-…;"agent 1c":the middle ~12,700 lines of the useQueuedPrompts.midTurnReconcile.test.tsx diff (diff lines ~1955-14657) were not read line-by-line — I covered its head (348-…;"agent reverse-audit (round 3)":mutation-measuring (vitest run) whether the it.each arms' echo and refresh-count assertions are individually discriminating — settled by reading the productio…;"agent reverse-audit (round 2)":I did not enumerate every caller of syncServerQueuedPrompts to confirm the latest.seq > notBefore fence covers each apply, so I could not settle whether the…。
未审查:反向审计——评审时间预算不足,未能开始第 4 轮。
收敛姿态下延后(第 8 轮,非阻断)——已记录,本轮不要求修改:共 13 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 8 轮发布了 2 条行内评论,其中 2 条是首次提出;上一轮发布了 1 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/hooks/useQueuedPrompts.ts(第 7 轮已出过发现,本轮又有 2 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 2 条 Critical),首次发现的速率没有下降(本轮 2,上一轮 1),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。
— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.23.3)
- A started event's uniqueness count could not see in-flight attachment rows, because the event carries no content to match on, so a text row that rendered alike claimed the echo of an attachment prompt: the image never reached the transcript and the caption was echoed a second time when the text row's own prompt started. Treat an attachment row that renders like the event as an uncountable candidate and degrade to no echo — each submit body still echoes its own row once its admission resolves. - The deferred-clear record was consumed by whichever snapshot landed first, including a flight dispatched before the clear was recorded, so a cancellation could be dropped without a DELETE and the cancelled message would later run. Anchor each record to the dispatch sequence and skip, without consuming, any pass that predates it. - A message the daemon refused at idle and the client then held was released later without that provenance, so its ordinary admission echoed it as sent on the activity mirror alone — which another client's prompt occupying the FIFO can lag — and dropped the queue row that was the user's only way to edit or cancel it. Stamp the provenance whenever the daemon said idle, held or not, so the released submission confirms against a snapshot. Each carries a witness that reddens without its fix, mutation-verified.
Follow-up list update (after
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
4 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- D9-12 a removal park with no consumer on the two explicit user-action arms (useQueuedPrompts.ts:1035) — already reported (round-8 review 5179281545, deferral at useQueuedPrompts.ts:1035)
- D9-13 the hand-copied stash-and-evict block (useQueuedPrompts.ts:2056) — already reported (round-8 review 5179281545, deferral at useQueuedPrompts.ts:2034)
- D9-14 the reasonless-rejection test pins neither its echo route nor its precondition (useQueuedPrompts.midTurnReconcile.test.tsx:1273) — already reported (round-6 review, deferral D6-7 at useQueuedPrompts.midTurnReconcile.test.tsx:1272)
- D9-15 the media fixture's identical bytes on both sides (useQueuedPrompts.midTurnReconcile.test.tsx:1559) — already reported (round-8 review 5179281545, deferral at useQueuedPrompts.midTurnReconcile.test.tsx:1553)
Unresolved, please confirm:
- [Critical] R1-1 (useQueuedPrompts.ts, thread 3949624084, anchor outdated) — could not be ruled this round: its mechanism is the root of the rendered-text-binding family that R2-1 carried forward, and this round's incremental scope was the two web-shel…
- [Critical] R2-2 (useQueuedPrompts.ts:2097, thread 3954525509) — could not be ruled this round: the !localRowExists arm it names was rewritten (its entry condition at :2138-2142 now vetoes on settle and on startedSinceSnapshot) but no agent traced whet…
- [Critical] R3-2 (useQueuedPrompts.ts, thread 3962365054, anchor outdated) — could not be ruled this round: its mechanism is clearInsertionFlag's parameter type being unable to carry the provenance flag on the explicit-insert path, and D9-1 confirms th…
- [Critical] R4-2 (docs/developers/qwen-serve-protocol.md:2729, thread 3962365087) — could not be ruled this round: the docs, packages/cli/src/serve/routes/session.ts and packages/acp-bridge/src/bridge.ts sites it names are context files outside this ro…
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): "agent reverse-audit (round 3)": line-by-line read of test-file lines ~2300-3360 (the placeholder/rival-image and boundAtSeq families, including the it.each at 2371); "agent reverse-audit (round 3)": line-by-line read of test-file lines ~3430-9055 (removal/edit/insert-action families, the it.each at 3405, reattachment and owner-guard tests); "agent reverse-audit (round 3)": line-by-line read of test-file lines ~9115-11867 (the cleared-fallback / parked-start / failed-removal family, ~28 tests that all drive the deferred-clear loop …; "agent reverse-audit (round 3)": line-by-line read of test-file lines ~12012-13850 (settle-echo and stash-consume families, including the two it.each(['refused','failed']) blocks at 12632 and…; "agent reverse-audit (round 3)": per-test mutation sweep of those unread regions — I mutated only this round's three source changes, so an existing test in the unread ranges that passes for the…, and 8 more.
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Deferred under the convergence posture (round 9, not a blocker) — recorded, not requested in this round:
packages/web-shell/client/hooks/useQueuedPrompts.ts:3739 — [review] D9-1 resubmittedAfterIdleRejection provenance lost at the insert-recovery resubmission (overwritten with false, and never persisted when the submission is deferred)packages/web-shell/client/hooks/useQueuedPrompts.ts:1954 — [review] D9-2 the chain-link docstring invariant is falsified by the held-row provenance stamp the same commit addedpackages/web-shell/client/hooks/useQueuedPrompts.ts:1022 — [review] D9-3 appendedBeforeResponsePromptIdsRef is the one per-prompt collection with no ceiling and no guaranteed consumer for settle-time writespackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:1668 — [review] D9-4 daemon fixtures copied verbatim (92 idle-rejection stubs, 105 started-event literals) instead of extracted helperspackages/web-shell/client/hooks/useQueuedPrompts.ts:841 — [review] D9-5 the displayed-twin guard also traps a row whose body already returned unbound, so a stale submitting row lingers for the whole turnpackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:2087 — [review] D9-6 the uncountableAttachmentRow guard has one witness pinning one of its clauses; three clause deletions each leave 215/215 greenpackages/web-shell/client/hooks/useQueuedPrompts.ts:385 — [review] D9-7 pendingPromptTextsMatch lets any blank-text row own an [image] rendering, including rows that carry no imagepackages/web-shell/client/hooks/useQueuedPrompts.ts:718 — [review] D9-8 the settledServerPromptIdsRef fence defeats the deferred-clear failure arm's documented re-settle of onCompletepackages/web-shell/client/hooks/useQueuedPrompts.ts:1087 — [review] D9-9 a synchronous throw leaves a settled entry in inflightRefreshRef; the comment cites a defence that cannot fire in that orderingpackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:1564 — [review] D9-10 the withMedia echo-and-drop branch is the one such witness with no removePendingPrompt negative assertionpackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:13538 — [probe] D9-11 the deferred-clear fence's handoff record site at :4041 has no witness in the under-direction (anchor -> 0 leaves 215/215 green)
Convergence: round 9 posted 2 inline comment(s), 2 of them reported for the first time; the previous round posted 2 (2 new). Findings keep coming back to the same files: packages/web-shell/client/hooks/useQueuedPrompts.ts (findings in round 8; 2 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)
Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (2 Critical(s)), the rate of first-time findings is not falling (this round 2, previous 2), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):
| standing Critical | attack surface | attacker-dependency | blast radius |
|---|---|---|---|
| (each standing Critical) | … | … | … |
Advisory only — it does not block this review.
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 4 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未决,请确认:共 4 条(原文未翻译,列表见上方英文部分)。
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)":line-by-line read of test-file lines ~2300-3360 (the placeholder/rival-image and boundAtSeq families, including the it.each at 2371);"agent reverse-audit (round 3)":line-by-line read of test-file lines ~3430-9055 (removal/edit/insert-action families, the it.each at 3405, reattachment and owner-guard tests);"agent reverse-audit (round 3)":line-by-line read of test-file lines ~9115-11867 (the cleared-fallback / parked-start / failed-removal family, ~28 tests that all drive the deferred-clear loop …;"agent reverse-audit (round 3)":line-by-line read of test-file lines ~12012-13850 (settle-echo and stash-consume families, including the two it.each(['refused','failed']) blocks at 12632 and…;"agent reverse-audit (round 3)":per-test mutation sweep of those unread regions — I mutated only this round's three source changes, so an existing test in the unread ranges that passes for the…,另有 8 条。
未审查:反向审计——在 5 轮的反审轮数上限内未收敛。
收敛姿态下延后(第 9 轮,非阻断)——已记录,本轮不要求修改:共 11 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 9 轮发布了 2 条行内评论,其中 2 条是首次提出;上一轮发布了 2 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/hooks/useQueuedPrompts.ts(第 8 轮已出过发现,本轮又有 2 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 2 条 Critical),首次发现的速率没有下降(本轮 2,上一轮 2),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。
— qwen3.8-max via Qwen Code /review (v0.23.3)
Three holes, two of them opened by the previous round's own fix: - The dispatch-anchor skip left the id out of the pass's suppression set, so the very flight that skipped the record went on to re-materialize the queue row the user had just cleared. It was not a flicker: while the client stays disconnected every later refresh is skipped, so the row persisted, and the reconnect pass then consumed the record against a snapshot reporting the prompt running, where the queued-only gate drops it without a DELETE. Add the id to the set the sync already consults; the record still survives for a pass that can prove something. - A record written by a submit body whose confirming snapshot had already landed had no later pass guaranteed to apply it, because the `.finally` refresh is suppressed in exactly that case and the anchor makes any flight already in flight skip the entry. Guarantee a newer pass. - The arm that echoes the body's own payload for a prompt the snapshot reports running carried no dedupe check. A settle inside its window clears the displayed marker, and an echo sourced from a bound row writes no re-read marker for the body, so a message echoed at start and then cancelled was echoed a second time above the cancelled turn. Consult the settled set — the only one of the three markers that survives a settle. The displayed term the report also suggested is deliberately absent: the echo helper already refuses a displayed id, so it would be a dead condition. Each carries a witness that reddens without it, mutation-verified.
Maintainer verification — real daemon + real browser, LinuxI built a local verification environment and drove this PR end to end against a real
Verdict: the central claim reproduces and the fix is load-bearing. Recommend merge. One new Minor finding (cosmetic, self-healing) and one pre-existing reachability gap are reported below; neither loses data and neither is a reason to hold the PR. How the race was made deterministicThe bug needs a window where the daemon is already idle but the browser's streaming mirror is not. In production that window is milliseconds. Here it is opened on purpose and asserted on every run:
A/B arms differ only in the two web-shell client files this PR touches:
Same daemon binary in both arms, so every difference below is the client half of this PR. Case A — the reported bugIdentical wire answer in both arms, observed in the page:
The daemon-side one-liner also checks out directly on Linux — an idle session answers Case B — image-only follow-up in the same windowThe case the PR calls out as the hard one: the daemon's summary carries no text, so the row has only the rendered placeholder to bind on. Observed
Case C — daemon queues the resubmission behind another client's turn, then the row is removedTo reach this deliberately, the browser's 10/10 checks pass on the PR arm:
One honest caveat on strength: I mutated Case D — the backward-compatibility claim, measuredStripping This is consistent with what the PR documents ( Finding 1 (new, Minor) — the resubmitted turn is folded into the previous transcript block, until a reloadAfter the fallback resubmits, the daemon reports 2 turns ( Controlled twice:
Nothing is lost and it self-heals, so I would not block on it — but per-turn duration and token attribution are wrong in the live view until the session is reopened. Worth a follow-up issue. Finding 2 (pre-existing, not this PR) —
|
| target | result |
|---|---|
packages/web-shell useQueuedPrompts.midTurnReconcile.test.tsx + daemon/session/actions.test.ts |
418 passed (217 in the reconcile file) |
packages/acp-bridge src/bridge.test.ts |
936 passed |
packages/sdk-typescript test/unit/DaemonClient.test.ts |
425 passed |
packages/cli src/serve/server.test.ts |
1277 passed |
Plus npm ci (full build) and npm run build --workspace packages/web-shell clean. All CI checks on 768f876421 are green; the BLOCKED merge state is the stale CHANGES_REQUESTED from the bot review on f9b4de664d.
Environment
host Linux 6.12.63 x86_64, Node v22.22.2
worktree /root/git/pr11289 @ 768f8764210f151e159fdea0a4cbc3cf091e487f
merge-base ae78d5b800a275b526d3e707ce3112926d34b156
daemon node dist/cli.js serve --port 4289 --token <random> --workspace <ws>
isolated QWEN_RUNTIME_DIR and HOME; workspace trusted (writes not blocked)
capabilities include session_mid_turn_message_mutation + _query
model local OpenAI-compatible mock, 127.0.0.1:18289, 40 chunks x 250ms, echoes ACK<prompt>
browser real Chromium via Playwright, viewport 1180x900, deviceScaleFactor 2
中文说明
维护者本地验证 —— 真实 daemon + 真实浏览器(Linux)
我在本地搭了验证环境,用真实的 qwen serve daemon + 真实 Chromium 端到端跑了这个 PR,正好补上 PR 描述里自己留下的那个缺口:
修复的浏览器侧行为 —— 只重投一次、daemon 排队时保留队列行、绝不取消已经在运行的回合 —— 由上面的 hook 级用例覆盖,不是通过驱动真实浏览器验证的。
结论:核心问题可复现,修复是真正起作用的(load-bearing)。建议合并。 下面报告 1 个新发现(Minor,纯显示且刷新自愈)和 1 个既有的可达性缺口;两者都不丢数据,都不构成拦截理由。
如何把竞态做成确定性的
这个 bug 需要一个窗口:daemon 已经空闲,但浏览器的流式状态镜像还没跟上。生产环境里这个窗口只有几毫秒。这里是刻意打开的,并且每次运行都断言它确实存在:
- 通过真实 daemon 跑一个真实的 ~10 秒回合(本地 OpenAI 兼容 mock 模型,40 chunk × 250ms,回复
ACK<提示词>)。 - 一旦客户端显示回合正在运行,就把它的事件投递挂起 —— 用
addInitScript包裹window.fetch,把GET /session/:id/events的 chunk 以及活动轮询(/session/:id/status、/sessions/live-state、/session/:id/turn-index)缓存下来,之后按序释放。不改任何应用代码,只改投递时序 —— 而时序本身就是这个竞态。 - 直接轮询 daemon,直到
GET /session/:id/status返回{"activeWorkState":"idle","hasActivePrompt":false}。 - 断言客户端仍然不同意 —— 输入框占位符仍是
Processing. New messages will be queued.(客户端自己的流式镜像)。每次运行该前置条件都成立 —— 仅用例 A 就有 11 次。 - 在这个窗口里输入追问并发送。客户端走 mid-turn 插入路径,daemon 因会话已空闲而拒绝。
A/B 两臂只相差本 PR 改动的那两个 web-shell 客户端文件:
| 分支臂 | 产物 |
|---|---|
pr |
head 768f876421 上 npm ci 产出的 bundle —— 已验证与在 head 上重新 npx vite build 的结果逐字节相同,说明这套构建配方能复现实际发布产物 |
base |
同一棵树,仅把 client/hooks/useQueuedPrompts.ts 与 client/components/QueuedPromptDisplay.tsx 回退到 merge-base ae78d5b800a(已用 git show ae78d5b800a:<file> 证明逐字节相同),再用同样方式构建 |
两臂使用同一个 daemon 二进制,因此下面所有差异都来自本 PR 的客户端那一半。
用例 A —— 被报告的那个问题
两臂在页面里观察到的 wire 应答完全相同:
POST /session/:id/mid-turn-message → 200 {"accepted":false,"reason":"session_idle"}
| base | 本 PR | |
|---|---|---|
| 是否报错 | Daemon rejected mid-turn message |
无 |
| 追问是否进入对话记录 | 完全不出现 | 出现一次 |
| 携带该追问、真正到达模型的提示词数 | 0 | 1 |
| wire 上的重投 | — | POST /session/:id/prompt → 202 |
| 运行次数 | 4 次,每次都是同样的 4 项失败 | 7 次,每次 9/9 全通过 |
daemon 侧那一行改动在 Linux 上也直接验证通过 —— 空闲会话返回 {"accepted":false,"reason":"session_idle"},重复 messageId 返回同样结果,之后 mid-turn-messages / pending-prompts 仍为空,说明这次拒绝没有转移所有权(daemon 日志:[mid-turn] session="…" rejected id "…": session idle)。
用例 B —— 同一窗口内的纯图片追问
也就是 PR 自己点名的那个难点:daemon 的摘要不带文本,队列行只能靠渲染出的占位文本去绑定。实测 GET /session/:id/pending-prompts → {"pendingPrompts":[{"text":"[image]", …}]}。
| base | 本 PR | |
|---|---|---|
| 是否报错 | Daemon rejected mid-turn message |
无 |
| 模型收到的带图提示词数 | 0 | 1([image: image/png]) |
| 对话记录里的图片气泡 | 0 | 1(未重复,也不是空占位) |
用例 C —— daemon 把重投排在另一个客户端的回合后面,然后该行被移除
为了稳定复现,浏览器的 POST /prompt 在页面网络层被挂起 4 秒,期间由第二个客户端占住 prompt FIFO。daemon 侧的一切都是真实的。
PR 臂 10/10 通过:
- daemon 确实持有
rival: state "running"+follow-up: state "queued"; - UI 把追问保留为
Queued on server...的队列行 —— 没有渲染成已发送; - 删除操作只
DELETE了排队中那个promptId,从未指向正在运行的那个; - 正在运行的回合完整跑完(回答的 39 个 chunk 全部到达);
- 被移除的追问从未执行(模型侧 0 次)。
关于证据强度的一点如实说明:我把本 PR 新增的 daemon-queued 确认逻辑 queuedInSnapshot 变异为 false 并重新构建,该变异体在 10 项检查下全部存活。在这个场景里,队列行之所以还在,来自 pending-prompts 的同步,因此本场景验证的是行为,并不能钉住那一个具体子句。钉住它的是 PR 里的 hook 级用例。
用例 D —— 对"向后兼容"这一说法的实测
把 wire 上的 reason 字段剥掉,就让真实 daemon 看起来像一个早于该字段的 daemon。此时新客户端会走旧的失败路径:报错、消息丢失(模型侧 0 次)。
这与 PR 自己的说明一致("客户端会同时保留自己的空闲判定")—— 而客户端那个信号恰恰就是滞后的那一个 —— 但有必要明确写出来:这个修复不是纯客户端的。 对 Web Shell 而言无所谓(bundle 由 daemon 提供,两半一起发布);只有当 @qwen-code SDK 使用方独立升级、却连到较旧的 daemon 时才有影响。
发现 1(新,Minor)—— 重投的那个回合在实时视图里被并进了上一个转录块,刷新后自愈
回退逻辑重投之后,daemon 报告的是2 个回合(GET /session/:id/turn-index),但实时 UI 只渲染出一个块:只有一个 Processed 头、且丢掉了耗时,两段回答被拼在一起。刷新同一会话后就正确显示为两个块、各自 Processed 11s。
做了两次对照:
- 不是我 harness 的突发投递造成的:在同样被挂起再释放的突发下,一个普通的第二条提示词会渲染成两个块(
["Processed 11s","Processed 11s"],未合并)。合并只发生在重投这条路径上。 - 不是窗口被人为放大造成的:把释放时机收紧到"拒绝一应答就释放"(接近生产时序),仍然合并。
没有数据丢失,且刷新即自愈,所以我不会因此拦截 —— 但在重新打开会话之前,实时视图里的单回合耗时与 token 归属是错的。值得开一个后续 issue。
发现 2(既有问题,与本 PR 无关)—— clearQueuedPrompts 目前在 UI 上没有触发入口
PR 的审查测试计划里要求"在这条追问正在重投时清空队列"。在当前的 Web Shell 里用户做不到:onClearQueuedMessages 在 ChatEditor / useComposerCore 里声明了,也从 App.tsx / ChatPane.tsx 传了进去,但从未被解构、也从未被调用 —— merge-base 与 head 都是如此,而且这三个文件本 PR 都没碰。所以页脚文案 Press ↑ to edit the latest queued message · Esc to clear queue 里承诺的手势是失效的;用例 C 里按 Esc 后,两个 pending prompt 原封不动。真正可达的移除路径是队列行自带的 Delete(removeServerPromptForAction),也就是上面用例 C 实际走的那条。
对本次评审的含义:这次"先确认再取消"工作中的"清空全部"那一半,目前只能从测试里到达,产品里到不了。这是保留 hook 级覆盖的理由,而不是改动本 PR 的理由。
改动涉及的测试文件,在 Linux 上重跑
PR 描述里的套件结果来自 macOS,并带有九个无关失败。在这台机器上,改动涉及的文件全绿:
| 目标 | 结果 |
|---|---|
packages/web-shell useQueuedPrompts.midTurnReconcile.test.tsx + daemon/session/actions.test.ts |
418 通过(其中 reconcile 文件 217) |
packages/acp-bridge src/bridge.test.ts |
936 通过 |
packages/sdk-typescript test/unit/DaemonClient.test.ts |
425 通过 |
packages/cli src/serve/server.test.ts |
1277 通过 |
另外 npm ci(完整构建)与 npm run build --workspace packages/web-shell 均干净通过。768f876421 上所有 CI 检查为绿;BLOCKED 的合并状态来自 bot 在 f9b4de664d 上那条已过期的 CHANGES_REQUESTED。
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
10 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- the releaseChainedPrompt chain-link docstring invariant is falsified by the held-row provenance stamp (useQueuedPrompts.ts:1954) — already reported (round-9 deferral D9-2); re-verified by probe this round
- the session-change test never delivers the snapshot it is named for (useQueuedPrompts.midTurnReconcile.test.tsx:3805) — already reported (round-5 deferral D5-6); re-verified by mutation this round
- the settledServerPromptIdsRef fence defeats the deferred-clear failure arm's documented re-settle of onComplete (useQueuedPrompts.ts:718) — already reported (round-9 deferral D9-8); re-verified by two flipping probes this round
- a removal park with no consumer on the two explicit user-action arms (useQueuedPrompts.ts:3404) — already reported (round-9 deferral D9-12 / round-8 deferral at :1035)
- the reasonless-rejection test pins neither its echo route nor its precondition (useQueuedPrompts.midTurnReconcile.test.tsx:1274) — already reported (round-9 deferral D9-14 / round-6 deferral D6-7)
- the uncountableAttachmentRow guard's files clause has no witness (useQueuedPrompts.midTurnReconcile.test.tsx:2361) — already reported (round-9 deferral D9-6 / round-2 deferral D2-1)
- the one unguarded quantifier assertion hiding loss of the still-queued sibling row (useQueuedPrompts.midTurnReconcile.test.tsx:5492) — already reported (round-5 deferral D5-8)
- the blank-row to '[image]' clause of pendingPromptTextsMatch is not discriminated by any test (useQueuedPrompts.ts:385) — already reported (round-2 deferral D2-1; correctness sibling is round-9 deferral D9-7)
- the matcher's row-side caption refusal is unwitnessed (useQueuedPrompts.ts:444) — already reported (round-8 deferral at useQueuedPrompts.midTurnReconcile.test.tsx:2123); verified this round NOT to be the clause round-6 D6-4 / X7-4 named
- the owned-removal term in the deferred-clear evidence check is unwitnessed (useQueuedPrompts.ts:1146) — already reported (round-8 deferral at useQueuedPrompts.midTurnReconcile.test.tsx:4636); verified this round NOT to be round-2 D2-2 or ro…
Unresolved, please confirm:
- [Critical] issue-level comment 5609598884 (round-4 autofix bot's claim that seven then-standing Criticals were addressed in 12d2fb1) — could not be ruled as a whole: five of the seven claims verify at HEAD, R6-3's named
clearedPromptRemovalIdsRef…
Not reviewed: test-efficacy probe — harnessValidated: null, so no mutation survivor is confirmed coverage: the positive control never produced a verdict because no probe file was green in the unmutated baseline (all five used a repo-relative include filter, packages/web-shell/client/hooks/…, which resolves to zero files under a config with root: 'client'). 25 survivors stand but unconfirmed; the harness's own green baseline (297/297) was verified separately.
Not reviewed: probe isolation — qwen review scratch-tree refused on this machine (the repository's local git config carries two includeIf.gitdir: entries pointing at a missing credentials file, which the command will not execute blind), and agent-prompt could not measure whether the review worktree was clean for the same reason. All three verification shards used out-of-tree copies of HEAD instead and each reported the review worktree clean at 768f876421 before and after; the shared worktree was verified clean at the end of the run.
Not reviewed: line-by-line walk of the test-file diff hunks at lines 306-15273 by the cross-file tracer (Agent 1c) — stopped at the agent tool budget; that territory was covered by the chunk agents (9/9 chunks reviewed), so this is optional depth rather than a missing required trace.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": mutation-probing the remaining guards my chunk's tests claim to pin — the sync-claim arm ( useQueuedPrompts.ts:2113-2131 ), the settled-id suppression inside s…; "agent reverse-audit (round 3)": I did not run the web-shell vitest suites, so no green/red evidence backs the walk — it is source-read only.; "agent reverse-audit (round 3)": I did not walk the submitPendingPrompt async body between :2055 and :2545 line-by-line (chunk 8's territory); I read only its head ( :1985-2055 ) and its …; "agent reverse-audit (round 3)": I did not determine whether the daemon's transcript event stream also delivers a started prompt's user message to a non-originating client ( appendLocalUserTran…; "agent 1c": line-by-line walk of the test-file diff hunks at lines 306-15273 (replaced by the deleted-name audit, the whole-file test run, and the sibling-consumer run desc….
Not reviewed: reverse audit — stopped before round 5 by the review time budget.
Not reviewed: "agent verify (round 3)" — pointed at diff lines it never opened: it made tool calls, but none of them read the diff.
Deferred under the convergence posture (round 10, not a blocker) — recorded, not requested in this round:
packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:1479 — [probe] The files term of refreshedInBody has no witnesspackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:5277 — [probe] Test asserts before the branch it names can runpackages/web-shell/client/hooks/useQueuedPrompts.ts:428 — [probe] The matcher's opening row-state guard is unwitnessedpackages/web-shell/client/hooks/useQueuedPrompts.ts:469 — [probe] The !serverImages refusal is unwitnessed and guards a throwpackages/web-shell/client/hooks/useQueuedPrompts.ts:1007 — [review] New comment asserts a sibling refusal that does not existpackages/web-shell/client/hooks/useQueuedPrompts.ts:1184 — [probe] Three removal-failure arms skip the owner-token re-checkpackages/web-shell/client/hooks/useQueuedPrompts.ts:2195 — [probe] refreshedInBody reset guard has no witnesspackages/web-shell/client/hooks/useQueuedPrompts.ts:2472 — [probe] Two of four markers in the new evidence guard are dead
Convergence: round 10 posted 4 inline comment(s), 4 of them reported for the first time; the previous round posted 2 (2 new). Findings keep coming back to the same files: packages/web-shell/client/hooks/useQueuedPrompts.ts (findings in rounds 8, 9; 4 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)
Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (6 Critical(s)), the rate of first-time findings is not falling (this round 4, previous 2), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):
| standing Critical | attack surface | attacker-dependency | blast radius |
|---|---|---|---|
| (each standing Critical) | … | … | … |
Advisory only — it does not block this review.
[Critical] R7-1 Still standing on the sibling route its own body names. The headline half IS fixed: the held arm of the idle-rejection requeue now stamps the provenance independently of shouldHold (useQueuedPrompts.ts:2945-2963, ...(result.reason === 'session_idle' ? { resubmittedAfterIdleRejection: true } : {})), pinned by confirms a released held row the daemon refused at idle (test :1896). But fallbackToPendingPrompt (useQueuedPrompts.ts:2662-2686) still takes only (id: number) and stamps no flag, and it is reached by a session_idle refusal at :3126-3129 (if (!result.accepted) { fallbackToPendingPrompt(prompt.id); return; }) and by the enqueue catch at :3156. Because the confirmation block is gated on the flag (:2055), that resubmission skips confirmation and lands in the tail echo at :2421-2425 — exactly the harm the author's own correction on this thread describes: the activity mirror can lag another client's prompt occupying the daemon FIFO, so a message the daemon still holds queued is echoed as sent and its row dropped. A third uncovered producer site, recoverAfterSettledInsert (:3753-3757), is posted inline this round as R10-1. This is a bounded family of producer sites, so the fix is to enumerate them: give fallbackToPendingPrompt the same session_idle stamp the requeue arm now carries. Fix constraint: the confirmation block is gated on prompt.resubmittedAfterIdleRejection && !localMessageAppended (useQueuedPrompts.ts:2055) and the tail echo on !latestSessionActiveRef.current || prompt.resubmittedAfterIdleRejection (:2421-2425), so the stamp must reach the stored row, not just a submitted copy — the defect R10-1 measures on the insert producer. Fix witness: a case in useQueuedPrompts.midTurnReconcile.test.tsx modelled on confirms a released held row the daemon refused at idle (test :1896) but reaching the refusal through the fallbackToPendingPrompt route at :3126-3129, asserting no echo and a surviving queued row; removing the stamp must turn it red.
[Critical] R2-1 Still standing as a class. All four entrances this comment named are closed at HEAD — the started-event consumer filters and requires uniqueness plus the uncountableAttachmentRow refusal (:1804-1853), the sync consumer added two-sided uniqueness (:817-833), the flag waiver is gone and attachment eligibility requires byte-identical fully hydrated media (:448-472), an undefined originator is refused on the attachment route (:437-441, pinned by does not bind an image fallback to an originator-less placeholder prompt), and if ((item.files?.length ?? 0) > 0) return false; sits at :456. The headline class claim is not closed: the matcher's key is still the daemon's rendering for any attachment-free row, and that branch never inspects server.content (:428-437). extractPromptText returns the first text block and ignores images, so it renders a text+image prompt as its caption; a text-only unbound row therefore matches an attachment-bearing prompt it does not own, the sync binds it at :838-841 with serverSideUnique short-circuited true by !rowHasAttachments, and a Remove on that row DELETEs the daemon's image message — verbatim this comment's stated harm. This round reproduced it by probe with no exotic interleaving: held rows [{id:1,text:"continue",img:1},{id:2,text:"continue",img:0}] against a snapshot listing one {promptId:"prompt-img", text:"continue", content:[{type:"image",data:"QUFB"}], originatorClientId: CLIENT_ID, state:"queued"} gives drained rows [...,{"id":2,"sid":"prompt-img","st":"queued","img":0}], and removeQueuedPrompt(2) issues removePendingPrompt [["prompt-img",{"sessionId":"session-a"}]]. The bind happens on the drain's own reconcile refresh at :3316 while the image row's POST is still in flight — the window the release chain exists for (:3272-3277). Immediate fail-closed step: in the !hasAttachments branch, refuse when contentToImages(server.content) or contentToFiles(server.content) is non-empty, mirroring the server.content === undefined refusal the attachment branch already has. Structurally, bind an unbound row only through an id authoritative for that row — the promptId the daemon returned to its own submit body, or a client-generated idempotency key the daemon echoes — because each refusal this matcher grows enumerates what it can compare while the entrance space is the daemon's rendering, which is unbounded; a fifth refusal will not be the last. Fix constraint: binds an originator-less snapshot prompt to a matching unbound row (test :4918) pins that a text-only row DOES bind a snapshot entry carrying no payload and no originator, so the exact-text route must survive for content-less entries and cannot be replaced by an id-only rule or made to require an originator. Fix witness: a case beside does not let a text row claim an attachment prompt that renders alike (test :2361) driving the snapshot route — two unbound rows ('continue' with an image, 'continue' text-only) and a snapshot listing the single captioned image prompt for CLIENT_ID, asserting the text-only row's serverPromptId stays undefined; removing the content guard must reproduce the probe state and turn it red. (This round's own probe-confirmed finding at :431 is folded into this re-report as its demonstrated entrance rather than posted separately, and its inline slot was also dropped as a same-line overlap with comment 3962365101.)
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 10 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。
未审查(原文为英文):test-efficacy probe — harnessValidated: null, so no mutation survivor is confirmed coverage: the positive control never produced a verdict because no probe file was green in the unmutated baseline (all five used a repo-relative include filter, packages/web-shell/client/hooks/…, which resolves to zero files under a config with root: 'client'). 25 survivors stand but unconfirmed; the harness's own green baseline (297/297) was verified separately.
未审查(原文为英文):probe isolation — qwen review scratch-tree refused on this machine (the repository's local git config carries two includeIf.gitdir: entries pointing at a missing credentials file, which the command will not execute blind), and agent-prompt could not measure whether the review worktree was clean for the same reason. All three verification shards used out-of-tree copies of HEAD instead and each reported the review worktree clean at 768f876421 before and after; the shared worktree was verified clean at the end of the run.
未审查(原文为英文):line-by-line walk of the test-file diff hunks at lines 306-15273 by the cross-file tracer (Agent 1c) — stopped at the agent tool budget; that territory was covered by the chunk agents (9/9 chunks reviewed), so this is optional depth rather than a missing required trace.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)":mutation-probing the remaining guards my chunk's tests claim to pin — the sync-claim arm ( useQueuedPrompts.ts:2113-2131 ), the settled-id suppression inside s…;"agent reverse-audit (round 3)":I did not run the web-shell vitest suites, so no green/red evidence backs the walk — it is source-read only.;"agent reverse-audit (round 3)":I did not walk the submitPendingPrompt async body between :2055 and :2545 line-by-line (chunk 8's territory); I read only its head ( :1985-2055 ) and its …;"agent reverse-audit (round 3)":I did not determine whether the daemon's transcript event stream also delivers a started prompt's user message to a non-originating client ( appendLocalUserTran…;"agent 1c":line-by-line walk of the test-file diff hunks at lines 306-15273 (replaced by the deleted-name audit, the whole-file test run, and the sibling-consumer run desc…。
未审查:反向审计——评审时间预算不足,未能开始第 5 轮。
未审查:"agent verify (round 3)"——启动 prompt 为它指定了 diff 中的行,但它从未打开:有工具调用,却没有一次读取 diff。
收敛姿态下延后(第 10 轮,非阻断)——已记录,本轮不要求修改:共 8 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 10 轮发布了 4 条行内评论,其中 4 条是首次提出;上一轮发布了 2 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/hooks/useQueuedPrompts.ts(第 8、9 轮已出过发现,本轮又有 4 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 6 条 Critical),首次发现的速率没有下降(本轮 4,上一轮 2),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。
[Critical] R7-1 Still standing on the sibling route its own body names. The headline half IS fixed: the held arm of the idle-rejection requeue now stamps the provenance independently of shouldHold (useQueuedPrompts.ts:2945-2963, ...(result.reason === 'session_idle' ? { resubmittedAfterIdleRejection: true } : {})), pinned by confirms a released held row the daemon refused at idle (test :1896). But fallbackToPendingPrompt (useQueuedPrompts.ts:2662-2686) still takes only (id: number) and stamps no flag, and it is reached by a session_idle refusal at :3126-3129 (if (!result.accepted) { fallbackToPendingPrompt(prompt.id); return; }) and by the enqueue catch at :3156. Because the confirmation block is gated on the flag (:2055), that resubmission skips confirmation and lands in the tail echo at :2421-2425 — exactly the harm the author's own correction on this thread describes: the activity mirror can lag another client's prompt occupying the daemon FIFO, so a message the daemon still holds queued is echoed as sent and its row dropped. A third uncovered producer site, recoverAfterSettledInsert (:3753-3757), is posted inline this round as R10-1. This is a bounded family of producer sites, so the fix is to enumerate them: give fallbackToPendingPrompt the same session_idle stamp the requeue arm now carries. Fix constraint: the confirmation block is gated on prompt.resubmittedAfterIdleRejection && !localMessageAppended (useQueuedPrompts.ts:2055) and the tail echo on !latestSessionActiveRef.current || prompt.resubmittedAfterIdleRejection (:2421-2425), so the stamp must reach the stored row, not just a submitted copy — the defect R10-1 measures on the insert producer. Fix witness: a case in useQueuedPrompts.midTurnReconcile.test.tsx modelled on confirms a released held row the daemon refused at idle (test :1896) but reaching the refusal through the fallbackToPendingPrompt route at :3126-3129, asserting no echo and a surviving queued row; removing the stamp must turn it red.
[Critical] R2-1 Still standing as a class. All four entrances this comment named are closed at HEAD — the started-event consumer filters and requires uniqueness plus the uncountableAttachmentRow refusal (:1804-1853), the sync consumer added two-sided uniqueness (:817-833), the flag waiver is gone and attachment eligibility requires byte-identical fully hydrated media (:448-472), an undefined originator is refused on the attachment route (:437-441, pinned by does not bind an image fallback to an originator-less placeholder prompt), and if ((item.files?.length ?? 0) > 0) return false; sits at :456. The headline class claim is not closed: the matcher's key is still the daemon's rendering for any attachment-free row, and that branch never inspects server.content (:428-437). extractPromptText returns the first text block and ignores images, so it renders a text+image prompt as its caption; a text-only unbound row therefore matches an attachment-bearing prompt it does not own, the sync binds it at :838-841 with serverSideUnique short-circuited true by !rowHasAttachments, and a Remove on that row DELETEs the daemon's image message — verbatim this comment's stated harm. This round reproduced it by probe with no exotic interleaving: held rows [{id:1,text:"continue",img:1},{id:2,text:"continue",img:0}] against a snapshot listing one {promptId:"prompt-img", text:"continue", content:[{type:"image",data:"QUFB"}], originatorClientId: CLIENT_ID, state:"queued"} gives drained rows [...,{"id":2,"sid":"prompt-img","st":"queued","img":0}], and removeQueuedPrompt(2) issues removePendingPrompt [["prompt-img",{"sessionId":"session-a"}]]. The bind happens on the drain's own reconcile refresh at :3316 while the image row's POST is still in flight — the window the release chain exists for (:3272-3277). Immediate fail-closed step: in the !hasAttachments branch, refuse when contentToImages(server.content) or contentToFiles(server.content) is non-empty, mirroring the server.content === undefined refusal the attachment branch already has. Structurally, bind an unbound row only through an id authoritative for that row — the promptId the daemon returned to its own submit body, or a client-generated idempotency key the daemon echoes — because each refusal this matcher grows enumerates what it can compare while the entrance space is the daemon's rendering, which is unbounded; a fifth refusal will not be the last. Fix constraint: binds an originator-less snapshot prompt to a matching unbound row (test :4918) pins that a text-only row DOES bind a snapshot entry carrying no payload and no originator, so the exact-text route must survive for content-less entries and cannot be replaced by an id-only rule or made to require an originator. Fix witness: a case beside does not let a text row claim an attachment prompt that renders alike (test :2361) driving the snapshot route — two unbound rows ('continue' with an image, 'continue' text-only) and a snapshot listing the single captioned image prompt for CLIENT_ID, asserting the text-only row's serverPromptId stays undefined; removing the content guard must reproduce the probe state and turn it red. (This round's own probe-confirmed finding at :431 is folded into this re-report as its demonstrated entrance rather than posted separately, and its inline slot was also dropped as a same-line overlap with comment 3962365101.)
— qwen3.8-max via Qwen Code /review (v0.23.3)
An explicit insert the daemon refused at idle while a hold was active went back to being held without the provenance that the mid-turn requeue now persists, so when the hold lifted the drain released it into an ordinary submission that skipped the confirmation and echoed on the activity mirror alone — the message rendered as sent and its queue row was dropped while the daemon still held it queued, and the displayed marker then stopped every later snapshot from restoring the row. Persist the provenance on the row that returns to the hold, and stop the insert-recovery copy from stripping it when a later insert is refused for a reasonless cause. Also correct the contract statements this and the previous round falsified: - a release-chain link CAN be an idle-rejected resubmission now, so its promise spans the confirming snapshot; the three comments that said otherwise, and the follow-up item closed on that premise, are corrected - the settle-time placeholder refusal is the only one of the three parked-text consumers that refuses the daemon's rendering, not a shared rule — say so, and name it as a precondition for any new replay site - a confirmed removal means the prompt never dispatched OR was aborted by it, not that it never ran; and refreshes are not strictly single-flight per session, so name the sequence fence as the guarantee it is - document the idle-provenance field itself, which read as state rather than provenance - scope the protocol's re-attach resend duty to the close window, require a new message id, cover the still-queued case a refused close leaves behind, and name the rings as bounded; scope the removal-retention clause to the running branch in the doc, the SDK JSDoc and the route comment alike - add the positive control a negatives-only insert test was missing
Round 25 of the local audit — dispositions (
|
|
Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
[Critical] Blocking finding(s) follow.
Partially reviewed — gaps disclosed.
Unresolved, please confirm:
- [Critical] R10-2 (useQueuedPrompts.ts:851) — the new sync claim arm splices an unbound submitting row; could not trace the mechanism at the reviewed commit within this round's budget
- [Critical] R10-3 (useQueuedPrompts.ts:1035) — hideSettledServerPrompt's last-chance echo gated on started-park; outside Agent 0's dimension, full body truncated in context file
- [Critical] R10-4 (useQueuedPrompts.ts:2155) — park-consume echo arm treats surviving pendingStartedByPromptIdRef entry; outside Agent 0's dimension, full body truncated
- [Critical] R10-5 (body) — still standing on the sibling route its own body names; body-only finding, full text truncated in context file
- [Critical] R10-6 (body) — still standing as a class; body-only finding, full text truncated in context file
Not reviewed: reverse audit — did not converge within 3 rounds (cap 5); round 3 reported findings in chunks 1, 3, 9.
Not reviewed: chunk 1 budget gap: did not read the shipped DaemonClient.enqueueMidTurnMessage body to confirm reason survives its response parsing — stopped at the agent tool budget.
Not reviewed: chunk 1 budget gap: did not verify the ring-eviction ordering behind 'an id reported nowhere may have aged out after delivery rather than been dropped' — stopped at the agent tool budget.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 3)": whether the closing-gate-before-attachment ordering ( bridge.ts:13792 gate vs :13806 assertReferences ) has a pinning test — my it(…) name grep over brid…; "agent reverse-audit (round 3)": the remainder of useQueuedPrompts.midTurnReconcile.test.tsx 's hunks (diff lines 178-15459) lies outside my assigned chunk range, so of that file I walked only…; "agent reverse-audit (round 3)": whether an await sits between the refresh pass's sequence fence ( useQueuedPrompts.ts:1102 , if (requestSeq !== refreshRequestSeqRef.current) return { status…; chunk 1: did not read the shipped DaemonClient.enqueueMidTurnMessage body to confirm reason survives its response parsing — I relied on the existing assertion at pa…; chunk 1: did not verify the ring-eviction ordering behind "an id reported nowhere may have aged out after delivery rather than been dropped" beyond the bound itself at …, and 1 more.
— [unverified] tag when the loop ended — the verifier never ruled on them, and they are not confirmed.
Deferred under the convergence posture (round 11, not a blocker) — recorded, not requested in this round:
packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:3873 — [review] Vacuous test: two mockImplementationOnce factories write the same resolvePending variablepackages/web-shell/client/hooks/useQueuedPrompts.ts:2069 — [review] Stash-and-evict loop duplicated five times verbatim instead of one helperpackages/web-shell/client/hooks/useQueuedPrompts.ts:3744 — [review] resubmittedAfterIdleRejection provenance flag written through functions whose Pick types exclude the fieldpackages/web-shell/client/hooks/useQueuedPrompts.ts:3776 — [review] recoverAfterSettledInsert's spread term is dead weight — clearInsertionFlag already wrote the flag
Convergence: round 11 posted 3 inline comment(s), 3 of them reported for the first time; the previous round posted 4 (4 new). Findings keep coming back to the same files: packages/web-shell/client/hooks/useQueuedPrompts.ts (findings in round 10; 2 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)
[Critical] R11-4 [certifies-falsely] The park-consume echo arm at useQueuedPrompts.ts:2161 treats 'park present + displayed marker absent' as echo still owed, but a settle inside the confirming-snapshot window already handled it — producing a duplicate or stale echo. (Unanchorable: snippet paraphrased, not in any hunk.) Fix: check settledServerPromptIdsRef or displayedServerPromptIdsRef before consuming the park. Fix witness: test asserting appendLocalUserMessage called exactly once when idle-rejected resubmission is cleared during confirming GET, started event and turn_complete both land, then snapshot arrives.
中文说明
仅完成部分审查,审查缺口已披露。
未决,请确认:共 5 条(原文未翻译,列表见上方英文部分)。
未审查(原文为英文):reverse audit — did not converge within 3 rounds (cap 5); round 3 reported findings in chunks 1, 3, 9.
未审查(原文为英文):chunk 1 budget gap: did not read the shipped DaemonClient.enqueueMidTurnMessage body to confirm reason survives its response parsing — stopped at the agent tool budget.
未审查(原文为英文):chunk 1 budget gap: did not verify the ring-eviction ordering behind 'an id reported nowhere may have aged out after delivery rather than been dropped' — stopped at the agent tool budget.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)":whether the closing-gate-before-attachment ordering ( bridge.ts:13792 gate vs :13806 assertReferences ) has a pinning test — my it(…) name grep over brid…;"agent reverse-audit (round 3)":the remainder of useQueuedPrompts.midTurnReconcile.test.tsx 's hunks (diff lines 178-15459) lies outside my assigned chunk range, so of that file I walked only…;"agent reverse-audit (round 3)":whether an await sits between the refresh pass's sequence fence ( useQueuedPrompts.ts:1102 , if (requestSeq !== refreshRequestSeqRef.current) return { status…;chunk 1:did not read the shipped DaemonClient.enqueueMidTurnMessage body to confirm reason survives its response parsing — I relied on the existing assertion at pa…;chunk 1:did not verify the ring-eviction ordering behind "an id reported nowhere may have aged out after delivery rather than been dropped" beyond the bound itself at …,另有 1 条。
— [unverified] 标记——验证者从未对它们作出裁决,它们不算已确认。
收敛姿态下延后(第 11 轮,非阻断)——已记录,本轮不要求修改:共 4 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 11 轮发布了 3 条行内评论,其中 3 条是首次提出;上一轮发布了 4 条(其中 4 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/hooks/useQueuedPrompts.ts(第 10 轮已出过发现,本轮又有 2 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)
[Critical] R11-4 [certifies-falsely] The park-consume echo arm at useQueuedPrompts.ts:2161 treats 'park present + displayed marker absent' as echo still owed, but a settle inside the confirming-snapshot window already handled it — producing a duplicate or stale echo. (Unanchorable: snippet paraphrased, not in any hunk.) Fix: check settledServerPromptIdsRef or displayedServerPromptIdsRef before consuming the park. Fix witness: test asserting appendLocalUserMessage called exactly once when idle-rejected resubmission is cleared during confirming GET, started event and turn_complete both land, then snapshot arrives.
— qwen3.8-max via Qwen Code /review (v0.23.3)
A settle that found the displayed marker already set left the start park behind, and a submit body still in flight read that park as an echo it owed and appended the same message a second time. The settle now drops the park with the marker; every reader that dedupes against the park also reads the settled set the same call adds. Three removal-failure paths never replayed a start that parked inside their DELETE, so a message the daemon ran reached no transcript and its stashed payload stayed reachable with no consumer. Both user-action arms and the clear-queue loop replay now, and the replay refuses the daemon's attachment placeholder because no removal arm is guaranteed to have stashed the payload behind it. A co-client's prompt starting inside our DELETE parked too — the originator check ran after the removal guard — so every replay site echoed a message the daemon's own stream also delivers. The check now gates the park. A start whose only payload source was a summary-only row echoed nothing and parked nothing, leaving the payload its body held with no consumer at settle. It parks now, and that body reads the park as its own admission. The sync matched rows the drain had stamped but not released, splicing or binding a row that was never POSTed; the link that fired afterwards read the missing row as a cancellation and bailed without POSTing, reporting or restoring anything. Unreleased rows are out of the match. A suppressed start records the one row that could own it, so a body whose admission fails after the daemon started the prompt echoes the payload it alone holds — and stays silent when two rows render alike, or when admission never started. That echo also suppresses the queue-failure report beside it, which would otherwise be false. Protocol doc: resending a mid-turn message after a refused close needs the DELETE first, and the removed verdict alone does not license the re-post.
Rounds 10-11 of the automated review, plus three local audit rounds — dispositionsEverything below landed in 8fe4a06. Each fix was reproduced first, then pinned by a witness that was verified by mutation (apply the fix → green; revert or narrow the fix → that witness and only that witness reddens; restore byte-identical). Counts: 15 new or updated witnesses, 12 mutations run, From the automated review (6 Criticals across rounds 10 and 11, all confirmed, none refuted)
From three local reverse-audit rounds over the same delta (round 26: four auditors; round 27: two; round 28: one fresh-eyes) Fixed with witnesses: a co-client's prompt parking inside our DELETE and being echoed locally on top of the daemon's stream; the sync binding an unreleased row to an id it cannot own; the failing-body echo firing when admission never started (an attachment upload rejects before the POST); the candidate count including rows the drain has not released, which manufactured an ambiguity and lost the echo; and a Reverted after analysis, so they are not re-proposed: the started handler's raw-text branch writing Corrected as inaccurate: five code comments and six test comments or fixtures that stated something the shipped daemon contradicts — among them three fixtures justifying Round 28 was intended as the confirmation round and found no Critical, but its three Majors — the reverted consult, the doc verdict and the toast — were all real and all fixed, so the clean-pass count restarted at zero rather than reaching the two consecutive dry rounds this branch has been holding itself to. Round 29 runs on 8fe4a06. Audit follow-ups deliberately not in this PR (updated, 24 items)This branch went through 28 rounds of a local reverse-audit battery (independent auditors over the whole diff each round, every finding recorded and dispositioned) plus 11 rounds of the automated PR review. Everything that was a correctness defect, and everything whose fix was cheap and local, is in the PR. What follows is the residue: items whose fix needs new machinery, a redesigned fixture, or a decision that belongs in its own change. Each was traced to code, not guessed. Echo fidelity
Coverage
Robustness
Fixes tried and rejected, recorded so they are not re-proposed Dispatching a refresh from the resubmission branch's claim arm — to restore a queue row for an admitted prompt the claiming snapshot did not list — was tried and reverted: the extra snapshot is taken while the daemon may still not list the prompt, and it drops the row the sync just materialized (a sync-materialized row carries no bind sequence, so the retention filter cannot exempt it). Two existing pins reddened. The window is real but benign — the started event's own refresh restores the row, and the echo is already guaranteed by the payload stash — and closing it properly needs a stamp or a fence on the materialized row, not an extra GET. Adding Writing Stashing the payload where the started handler suppresses an ambiguous echo: at that moment the handler cannot attribute the event to a row, which is why it suppressed, so the stash would attach one message's attachments to another's echo. The shipped fix instead lets the body that holds the payload consume a park that named its row as the only candidate. Widening the removal replay's bound-row source to any payload-complete row, so that a text-only row outranks the parked rendering: applied and reverted. A hydration-loss row carries the daemon's |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- X12-1 settleCompletionCallback's new settled-set early return discards the callback the three removal-failure arms re-register (useQueuedPrompts.ts:732) — already reported on this PR as round-9 D9-8 and re-verified at round 10; not re-poste…
- X12-2 resubmittedAfterIdleRejection written through functions whose Pick<> types exclude the field, and not compared by areQueuedPromptsEqual (useQueuedPrompts.ts:3879) — already on this PR's round-11 deferral record at useQueuedPrompts.ts:…
Unresolved, please confirm:
- [Critical] R10-2 (useQueuedPrompts.ts:851) — the sync claim arm splicing an unbound submitting row: carried unresolved since round 10; this round's budget went to the new findings and the audit loop stopped on its time budget, so the original mechanis…
- [Critical] R10-3 (useQueuedPrompts.ts:1035) — hideSettledServerPrompt's last-chance echo gated on the started-park: not re-derived this round
- [Critical] R10-4 (useQueuedPrompts.ts:2155) — the park-consume echo arm treating a surviving pendingStartedByPromptIdRef entry: not re-derived this round
- [Critical] R10-5 (body-only) — still standing on the sibling route its own body names: not re-derived this round
- [Critical] R10-6 (body-only) — still standing as a class: not re-derived this round
- [Critical] R4-2 clause (b) (docs/developers/qwen-serve-protocol.md:2786 and its two mirrors in packages/sdk-typescript/src/daemon/types.ts and packages/cli/src/serve/routes/session.ts) — clause (a) is fixed and verified, but the prescribed displacemen…
- [Critical] Pre-anchor threads R3-2, R4-1, R4-3, R4-4, R5-2, R6-1, R6-2, R6-4, R9-1 — the sites this diff touches were read and no regression of their fixes was found, but each original mechanism was not re-derived end to end, so these are 'cannot tell…
Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": whether a settle-time promotion can also fail on FIFO exhaustion ( maxPendingPromptsPerSession ) inside bridgeApi.sendPrompt — which would be a second silent …; "agent reverse-audit (round 2)": locating the useDaemonSessionOwnerGuard implementation to measure how often snapshot.isCurrent() flips with sessionId unchanged (three greps returned only…; "agent reverse-audit (round 3)": did not execute the web-shell vitest suite to confirm the returned-unbound → start → settle sequence empirically; the finding rests on a code trace of :2451 , …; "agent reverse-audit (round 3)": the doc sentences "A closing session never promotes what remains queued… land in no ring" and "A close the child then refuses leaves the session live with whate…; "agent reverse-audit (round 3)": the sentence "one already running is hidden until its aborted turn settles" — I read only the tail of removePendingPrompt ( bridge.ts:13695-13708 ) and did no…, and 3 more.
Not reviewed: reverse audit — stopped before round 5 by the review time budget.
Deferred under the convergence posture (round 12, not a blocker) — recorded, not requested in this round:
docs/developers/qwen-serve-protocol.md:2786 — [review] The newly documented settle-during-close-*authorization* bpackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:1732 — [review] The PR pastes the same mock-wiring boilerplate into everypackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:2415 — [probe] one resolver delivery in the new fence tests is unpinnedpackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:11338 — [probe] The new test echoes nothing when a parked start still endspackages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:15445 — [probe] The last of the four new tests reaches the brand-new failipackages/web-shell/client/hooks/useQueuedPrompts.ts:433 — [probe] The text route's foreign-originator refusal is untested; tpackages/web-shell/client/hooks/useQueuedPrompts.ts:446 — [probe] The "only a text-less row can own a placeholder rendering"packages/web-shell/client/hooks/useQueuedPrompts.ts:855 — [probe] The new server-side uniqueness count is taken over the whopackages/web-shell/client/hooks/useQueuedPrompts.ts:990 — [probe] The new sentinel refusal drops a user message whose literapackages/web-shell/client/hooks/useQueuedPrompts.ts:1063 — [review] The settle-time echo writes an appendedBeforeResponsePromppackages/web-shell/client/hooks/useQueuedPrompts.ts:1063 — [probe] The skip-write guard for a body that already returned unbopackages/web-shell/client/hooks/useQueuedPrompts.ts:1128 — [review] The new notBefore optional parameter is never supplied bypackages/web-shell/client/hooks/useQueuedPrompts.ts:1196 — [probe] The deferred-clear loop's "a removal this client already opackages/web-shell/client/hooks/useQueuedPrompts.ts:1866 — [probe] The unreleased-row filter on unboundMatches (text rows) ispackages/web-shell/client/hooks/useQueuedPrompts.ts:1946 — [probe] The prompt ? [] : half of this round's new soleCandidateRopackages/web-shell/client/hooks/useQueuedPrompts.ts:2275 — [probe] This round added two things beside the deferred-clear recopackages/web-shell/client/hooks/useQueuedPrompts.ts:2402 — [probe] The boundAtSeq stamp on the idle-resubmission bind path (bpackages/web-shell/client/hooks/useQueuedPrompts.ts:2552 — [review] Two of this new guard's four disjuncts are provably alwayspackages/web-shell/client/hooks/useQueuedPrompts.ts:2598 — [probe] The new confirmed-removal branch of the idx === -1 arm clepackages/web-shell/client/hooks/useQueuedPrompts.ts:2697 — [probe] The new toast suppression recognises only the attachment/p- …and 1 more (see the run report)
Convergence: round 12 posted 6 inline comment(s), 6 of them reported for the first time; the previous round posted 3 (3 new). Findings keep coming back to the same files: packages/web-shell/client/hooks/useQueuedPrompts.ts (findings in round 11; 4 more now); docs/developers/qwen-serve-protocol.md (findings in round 11; 2 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)
Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (6 Critical(s)), the rate of first-time findings is not falling (this round 6, previous 3), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):
| standing Critical | attack surface | attacker-dependency | blast radius |
|---|---|---|---|
| (each standing Critical) | … | … | … |
Advisory only — it does not block this review.
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未决,请确认:共 7 条(原文未翻译,列表见上方英文部分)。
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)":whether a settle-time promotion can also fail on FIFO exhaustion ( maxPendingPromptsPerSession ) inside bridgeApi.sendPrompt — which would be a second silent …;"agent reverse-audit (round 2)":locating the useDaemonSessionOwnerGuard implementation to measure how often snapshot.isCurrent() flips with sessionId unchanged (three greps returned only…;"agent reverse-audit (round 3)":did not execute the web-shell vitest suite to confirm the returned-unbound → start → settle sequence empirically; the finding rests on a code trace of :2451 , …;"agent reverse-audit (round 3)":the doc sentences "A closing session never promotes what remains queued… land in no ring" and "A close the child then refuses leaves the session live with whate…;"agent reverse-audit (round 3)":the sentence "one already running is hidden until its aborted turn settles" — I read only the tail of removePendingPrompt ( bridge.ts:13695-13708 ) and did no…,另有 3 条。
未审查:反向审计——评审时间预算不足,未能开始第 5 轮。
收敛姿态下延后(第 12 轮,非阻断)——已记录,本轮不要求修改:共 21 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 12 轮发布了 6 条行内评论,其中 6 条是首次提出;上一轮发布了 3 条(其中 3 条首次提出)。发现反复回到同一批文件:packages/web-shell/client/hooks/useQueuedPrompts.ts(第 11 轮已出过发现,本轮又有 4 条);docs/developers/qwen-serve-protocol.md(第 11 轮已出过发现,本轮又有 2 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 6 条 Critical),首次发现的速率没有下降(本轮 6,上一轮 3),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| ### Mid-turn messages | ||
|
|
||
| `POST /session/:id/mid-turn-message` accepts `{ "message": "...", "messageId": "<optional-message-id>" }`. A successful admission returns `{ "accepted": true, "messageId": "<id>" }` and transfers ownership to the daemon: the message is drained into the active turn or promoted into the normal prompt FIFO when the session becomes idle. Clients using `session_mid_turn_message_query` send a stable `messageId`; repeating it is idempotent while it remains queued, pending, or in the bounded reconciliation rings. A full queue rejects a new request without taking ownership. New clients connected to an older daemon detect the missing capability and retain their legacy local fallback. | ||
| `POST /session/:id/mid-turn-message` accepts `{ "message": "...", "messageId": "<optional-message-id>" }`. A successful admission returns `{ "accepted": true, "messageId": "<id>" }` and transfers ownership to the daemon: the message is drained into the active turn or promoted into the normal prompt FIFO when the session becomes idle. Clients using `session_mid_turn_message_query` send a stable `messageId`; repeating it is idempotent while it remains queued, pending, or in the bounded reconciliation rings. A rejection of a request the daemon validated returns `{ "accepted": false }` and never transfers ownership: an open session with no prompt admitted to its prompt FIFO and no active Goal turn reports `{ "accepted": false, "reason": "session_idle" }` so the client can resubmit the message as an ordinary prompt instead of surfacing a failure. For a genuinely new admission, a `content` block referencing an attachment the session no longer holds — or an invalid reference — is declined before the idle and queue verdicts: the request is answered `410 session_attachment_gone` (or `400 invalid_session_attachment_reference`) with an `{ "error", "code" }` body, which carries no `reason` even when the session is idle. Two earlier gates preempt it: a repeated `messageId` whose payload still matches settles idempotently with `{ "accepted": true, "messageId" }` even if the attachment has since been removed, and a session that is closing or authorizing a close is refused with a reasonless `{ "accepted": false }`. The verdict describes only what can drain a mid-turn message, not everything the session may hold — a session snapshot can still report `hasActivePrompt: true` for it, for example while a deferred restore prompt is parked. A reasonless rejection has another cause — the queue is full, the session is closing or authorizing a close, the queued inline-attachment budget is exhausted, or a repeated `messageId` no longer matches the payload the daemon holds. The daemon keeps the payload it already admitted rather than replacing it, but that is not a delivery promise: a promoted message the client removed disappears from pending-prompt snapshots at once — one that had not started is dropped where it stands, one already running is hidden until its aborted turn settles — so the removal response is the only `removed = true` a client ever observes. A closing session never promotes what remains queued, and a turn that settles while a close is still being authorized hands the queue to the admission gate, which refuses it, so those messages are dropped rather than promoted and land in no ring. A close the child then refuses leaves the session live with whatever remained queued still listed and nothing scheduled to promote it until some later turn ends. A client re-attaching after a refused close must therefore resend every id it enqueued during that window that `GET /session/:id/mid-turn-messages` still reports as queued, plus any id it holds no other delivery evidence for. A still-queued id is a payload the daemon still owns and a later turn's settle promotes on its own, so a resend that skips the DELETE delivers that message twice. Where `session_mid_turn_message_mutation` is advertised, resending one means `DELETE /session/:id/mid-turn-messages/:messageId` first and then posting the text again under a new `messageId`, because a repeated id is acked idempotently and re-arms nothing. Where it is not advertised there is no way to release the payload, so a still-queued id must not be re-posted at all. `{ "removed": false }` means the id was already injected or completed and must not be re-posted. `{ "removed": true }` releases the payload, but for an id a settle promoted between the query and the delete it covers two different outcomes: the prompt FIFO entry was spliced before it ever dispatched, which is safe to re-post, or the promoted prompt had already started and the delete aborted a turn that had seen the message, which a re-post then sends a second time. The verdict does not distinguish the two, so a client that cannot rule out the second — it has not watched this session's prompts start — should prefer waiting for the promotion over deleting. An id in neither the queue nor either ring has nothing left to delete — a promoted id stays deletable through the same route until it settles, and reports in `promotedMessageIds` — and both rings are bounded, so an id reported nowhere may have aged out after delivery rather than been dropped. A missing `reason` is therefore not evidence of a busy session. Daemons that predate `reason` omit it in every case, so clients keep their own idle detection alongside it. New clients connected to an older daemon detect the missing capability and retain their legacy local fallback. |
There was a problem hiding this comment.
[Critical] R11-1: (fix-induced) [certifies-falsely] [new-surface] The sentence this round adds — { "removed": false } means the id was already injected or completed and must not be re-posted — gives that verdict a meaning the code does not implement, and turns it into a prohibition that defeats the resend procedure the same paragraph prescribes. removeMidTurnMessage returns { removed: false } from its not-found arm for any id in neither midTurnMessageQueue nor pendingPromptList, and records nothing in either ring on that path — so the same verdict also covers an id the daemon dropped without ever delivering it. The page already says so six lines below ("already injected, completed, or not found"), and this paragraph itself documents the drop two sentences earlier.
Concretely: a turn that settles while a close is still being authorized splices the queue into promotion (shouldSettleMidTurnQueue gates on entry.closing, not on activeWorkCloseInFlight), sendPrompt refuses with session_closing, the rejection is swallowed by void result.catch(...), onPromptAdmitted never fires, and the id lands in no ring. A re-attaching client then follows this page: DELETE first, which answers { removed: false }, and this sentence tells it the id "was already injected or completed and must not be re-posted" — so it aborts the resend. The user's mid-turn message is gone with no error on either side, and the paragraph's own ring hedge ("an id reported nowhere may have aged out after delivery rather than been dropped") is what makes an unconditional prohibition wrong rather than merely loose.
Witness:
vitest run src/bridge.test.ts -t "lets another attached client remove an undrained session message"
-> [mid-turn] session="sess:/work/a" remove missed messageId="bc9378f1-..." (already drained or completed)
-> Tests 1 passed | 935 skipped (936) ; the second DELETE asserts { removed: false } (bridge.test.ts:37206-37214)
prose A/B: git show ae78d5b8:docs/.../qwen-serve-protocol.md | grep -c "must not be re-posted" -> 0
HEAD -> 1 (line 2786), with line 2792 still reading "or not found"
rememberMidTurnId call sites: bridge.ts:9394 (promoted ring, from onPromptAdmitted), :13916, :13925 (settled ring, from removeMidTurnMessage) — none on the drop path
Narrow the sentence to what the code guarantees and keep the decision rule with the rings, for example:
`{ "removed": false }` means only that the daemon no longer holds a deletable payload for
that id; the cause is not distinguished — it was already injected or completed, it was dropped
without delivery (see the settle-during-close-authorization case above), or it aged out of both
rings. An id that appears in `settledMessageIds` or `promotedMessageIds` must not be re-posted;
an id in neither ring carries no delivery evidence, so a client that holds none of its own must
treat it as undelivered and re-post it under a new `messageId`.
The rewrite must stay consistent with the unchanged sentence at docs/developers/qwen-serve-protocol.md:2792 ("{ "removed": false } means the message was already injected, completed, or not found") and with the ring rule at :2788 ("An id in either ring must not be resent"), so the page does not carry two meanings for one verdict.
中文说明
本轮新增的这句 —— { "removed": false } 表示该 id 已经被注入或已完成,不得重新投递 —— 赋予了该返回值代码并不具备的含义,并且这个禁令恰好抵消了同一段落所规定的重发流程。removeMidTurnMessage 的 not-found 分支会对任何既不在 midTurnMessageQueue、也不在 pendingPromptList 中的 id 返回 { removed: false },且该路径不会写入任何一个 ring —— 因此同一个返回值也覆盖了「daemon 从未投递就丢弃」的 id。本页六行之后原本就写着「already injected, completed, or not found」,而这一段自己在两句之前就描述了这种丢弃。
具体路径:当一次回合在关闭仍被授权(authorizing)期间结算时,队列会被拼入提升流程(shouldSettleMidTurnQueue 只判断 entry.closing,不判断 activeWorkCloseInFlight),sendPrompt 以 session_closing 拒绝,该拒绝被 void result.catch(...) 吞掉,onPromptAdmitted 从不触发,于是这个 id 不落入任何 ring。随后重新连接的客户端照本页执行:先 DELETE,得到 { removed: false },而这句话告诉它该 id「已经被注入或已完成,不得重新投递」—— 于是它放弃重发。用户的 mid-turn 消息就此消失,双方都没有任何错误;而这一段自己的 ring 保留语句(「一个在任何地方都查不到的 id,可能是投递后老化淘汰,而非被丢弃」)正说明无条件禁令是错误的,而不只是措辞宽松。
建议把这句收窄到代码真正保证的范围,并把判定规则留给两个 ring(见上方英文的替换文本)。改写必须与 docs/developers/qwen-serve-protocol.md:2792 的原有句子以及 :2788 的 ring 规则保持一致,避免同一页对同一个返回值给出两种含义。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| ### Mid-turn messages | ||
|
|
||
| `POST /session/:id/mid-turn-message` accepts `{ "message": "...", "messageId": "<optional-message-id>" }`. A successful admission returns `{ "accepted": true, "messageId": "<id>" }` and transfers ownership to the daemon: the message is drained into the active turn or promoted into the normal prompt FIFO when the session becomes idle. Clients using `session_mid_turn_message_query` send a stable `messageId`; repeating it is idempotent while it remains queued, pending, or in the bounded reconciliation rings. A full queue rejects a new request without taking ownership. New clients connected to an older daemon detect the missing capability and retain their legacy local fallback. | ||
| `POST /session/:id/mid-turn-message` accepts `{ "message": "...", "messageId": "<optional-message-id>" }`. A successful admission returns `{ "accepted": true, "messageId": "<id>" }` and transfers ownership to the daemon: the message is drained into the active turn or promoted into the normal prompt FIFO when the session becomes idle. Clients using `session_mid_turn_message_query` send a stable `messageId`; repeating it is idempotent while it remains queued, pending, or in the bounded reconciliation rings. A rejection of a request the daemon validated returns `{ "accepted": false }` and never transfers ownership: an open session with no prompt admitted to its prompt FIFO and no active Goal turn reports `{ "accepted": false, "reason": "session_idle" }` so the client can resubmit the message as an ordinary prompt instead of surfacing a failure. For a genuinely new admission, a `content` block referencing an attachment the session no longer holds — or an invalid reference — is declined before the idle and queue verdicts: the request is answered `410 session_attachment_gone` (or `400 invalid_session_attachment_reference`) with an `{ "error", "code" }` body, which carries no `reason` even when the session is idle. Two earlier gates preempt it: a repeated `messageId` whose payload still matches settles idempotently with `{ "accepted": true, "messageId" }` even if the attachment has since been removed, and a session that is closing or authorizing a close is refused with a reasonless `{ "accepted": false }`. The verdict describes only what can drain a mid-turn message, not everything the session may hold — a session snapshot can still report `hasActivePrompt: true` for it, for example while a deferred restore prompt is parked. A reasonless rejection has another cause — the queue is full, the session is closing or authorizing a close, the queued inline-attachment budget is exhausted, or a repeated `messageId` no longer matches the payload the daemon holds. The daemon keeps the payload it already admitted rather than replacing it, but that is not a delivery promise: a promoted message the client removed disappears from pending-prompt snapshots at once — one that had not started is dropped where it stands, one already running is hidden until its aborted turn settles — so the removal response is the only `removed = true` a client ever observes. A closing session never promotes what remains queued, and a turn that settles while a close is still being authorized hands the queue to the admission gate, which refuses it, so those messages are dropped rather than promoted and land in no ring. A close the child then refuses leaves the session live with whatever remained queued still listed and nothing scheduled to promote it until some later turn ends. A client re-attaching after a refused close must therefore resend every id it enqueued during that window that `GET /session/:id/mid-turn-messages` still reports as queued, plus any id it holds no other delivery evidence for. A still-queued id is a payload the daemon still owns and a later turn's settle promotes on its own, so a resend that skips the DELETE delivers that message twice. Where `session_mid_turn_message_mutation` is advertised, resending one means `DELETE /session/:id/mid-turn-messages/:messageId` first and then posting the text again under a new `messageId`, because a repeated id is acked idempotently and re-arms nothing. Where it is not advertised there is no way to release the payload, so a still-queued id must not be re-posted at all. `{ "removed": false }` means the id was already injected or completed and must not be re-posted. `{ "removed": true }` releases the payload, but for an id a settle promoted between the query and the delete it covers two different outcomes: the prompt FIFO entry was spliced before it ever dispatched, which is safe to re-post, or the promoted prompt had already started and the delete aborted a turn that had seen the message, which a re-post then sends a second time. The verdict does not distinguish the two, so a client that cannot rule out the second — it has not watched this session's prompts start — should prefer waiting for the promotion over deleting. An id in neither the queue nor either ring has nothing left to delete — a promoted id stays deletable through the same route until it settles, and reports in `promotedMessageIds` — and both rings are bounded, so an id reported nowhere may have aged out after delivery rather than been dropped. A missing `reason` is therefore not evidence of a busy session. Daemons that predate `reason` omit it in every case, so clients keep their own idle detection alongside it. New clients connected to an older daemon detect the missing capability and retain their legacy local fallback. |
There was a problem hiding this comment.
[Critical] R12-4: [certifies-falsely] [new-surface] The new re-attach/resend rule and its "nothing left to delete" equivalence treat GET /session/:id/mid-turn-messages as a complete listing of the ids the daemon still holds, but the listing is filtered: getPendingPrompts skips prompts with removed = true, and anonymous enqueues are handled off the shared queue surface. An id can therefore be absent from the listing while its payload is still held — and the procedure reads that absence as delivery evidence.
An integrator posts three mid-turn messages during a running turn without X-Qwen-Client-Id (optional per this page), a close is authorized and then refused by the child, and the client rebuilds its resend set from the GET listing exactly as prescribed. Ids the listing omits fall under "an id in neither the queue nor either ring has nothing left to delete", are read as already delivered, and are never re-posted. The messages are silently dropped by a client that followed the documented recovery step for step, and neither side reports an error — which is why neither the integrator's tests nor this repo's will show it.
Witness:
witness: [probe] instrumented run of the PR's own test `keeps anonymous enqueues off the shared
queue surface` (packages/acp-bridge/src/bridge.test.ts:39734), instrumented to print the id the
daemon assigned and then re-read the listing:
PROBE-G01 anonymousId=cf03270d-4a2c-4f2f-a02b-425d0f7a7f88
-> the id is not present in the GET /session/:id/mid-turn-messages listing the resend rule reads
getPendingPrompts filters `!p.removed` (bridge.ts:13505-13506), so a removed-but-running prompt
is invisible to the same listing while its payload is still held
Qualify both sentences with the visibility rule rather than leaving the listing to read as complete — for example: "the snapshot lists only enqueues the daemon still holds and attributes to this client; an id absent from it is not evidence of delivery. A client that owes the user a delivery for an id it cannot see must re-post that id under a new messageId rather than treating the absence as settled."
The fix must not contradict packages/acp-bridge/src/bridge.ts:13983-13984 ("Anonymous enqueues (live steering) …"), which is why the listing is filtered in the first place, and it must keep the ring rule at :2788 intact — an id in either ring still must not be resent.
中文说明
新增的「重新连接后重发」规则及其「已无可删除内容」的等价表述,把 GET /session/:id/mid-turn-messages 当成了 daemon 仍持有 id 的完整清单,但这个清单是被过滤的:getPendingPrompts 会跳过 removed = true 的提示词,匿名投递也不走共享队列表面。因此一个 id 可能在载荷仍被持有的情况下从清单中消失 —— 而该流程把这种「消失」当成了投递证据。
场景:集成方在一个运行中的回合里不带 X-Qwen-Client-Id(本页允许省略)投递三条 mid-turn 消息,随后关闭被授权、又被子进程拒绝,客户端按规定从 GET 清单重建重发集合。清单中缺失的 id 落入「既不在队列也不在任一 ring,已无可删除内容」,被读作已投递,于是永不重发。一个逐步照文档执行的客户端就这样静默丢弃了消息,双方都没有错误 —— 所以集成方和本仓库的测试都看不出来。
建议:给这两句加上可见性限定,而不要让清单读起来像完整的(见上方英文示例文本)。修复不得与 packages/acp-bridge/src/bridge.ts:13983-13984(匿名投递的注释,正是清单被过滤的原因)冲突,也必须保留 :2788 的 ring 规则 —— 位于任一 ring 中的 id 仍然不得重发。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| let echoedParkId: string | undefined; | ||
| if (admissionStarted && eventCannotReproducePayload(prompt)) { |
There was a problem hiding this comment.
[Critical] R12-1: [certifies-falsely] [regression] The new submit-failure echo arm reads admissionStarted as "the daemon may already hold this message", but onAdmissionStarted fires immediately before the POST is dispatched (client/daemon/session/actions.ts:1306, ahead of session.submitPrompt). A definite rejection that arrives after that callback — a DaemonHttpError, a 413 — is therefore treated as a possible delivery: the arm echoes a parked payload into the transcript and skips reportError, so the user is shown a message the daemon never accepted and is never told it failed.
This is a regression, not new surface: at the merge base the same tail was an unconditional reportError(error, t('queue.queueFailed')), so every rejection was reported. The foreign-originator case is the reachable one — a submitter with no client id (the terminal CLI on the same daemon session, or an older daemon) leaves originatorClientId undefined, the park is written, and the rejection then suppresses the failure report. An explicitly foreign originator is correctly excluded, so the hole is the omitted-originator fail-open, not co-client prompts.
Witness:
witness (5-arm probe, PR arm; 排队消息失败 = queue.queueFailed):
ARM 1 foreign-park(originator omitted) + DaemonHttpError(413) after onAdmissionStarted
appendLocalUserMessage(new): ["",[{"data":"cG5n","mimeType":"image/png"}],{"promptId":"foreign-1"},null]
reportError: (none) <- echoed a message the daemon refused, and reported no failure
ARM 2 foreign-park + TypeError (indeterminate) -> same echo, reportError: (none)
ARM 3 no park + 413 -> appendLocalUserMessage: (none); reportError: DaemonHttpError: 排队消息失败
ARM 4 explicit co-client originator + 413 -> appendLocalUserMessage: (none); reportError: DaemonHttpError: 排队消息失败
ARM 5 foreign park + 413, admission NOT started -> reportError fired; restoreImages called
fix flip (added `!(error instanceof DaemonHttpError)` at :2674, re-ran, restored):
ARM 1 -> appendLocalUserMessage(new): (none); reportError: DaemonHttpError: 排队消息失败
A/B (merge-base ae78d5b8 hook, proven to lack the change: grep -c echoedParkId = 0):
BASE arm1: appendLocalUserMessage(new): (none) reportError: DaemonHttpError: 排队消息失败
PR arm1: appendLocalUserMessage("",[image],{promptId:"foreign-1"}) reportError: (none)
Record at park time whether the event definitely carried this client's originator id, and require that (not merely admissionStarted) before letting a rejection echo instead of reporting — or exclude a definite rejection (error instanceof DaemonHttpError) from the echo arm, which is the one-line flip the probe measured. Note the same fix should reconsider restoreQueuedPromptsToEditor, which :2696 still gates on !admissionStarted: on a 413 the draft is not restored either.
An omitted originator must keep meaning "possibly ours" everywhere else — shouldAppendLocalUserMessage and the started handler both rely on that reading — so the new evidence has to be recorded at park time rather than inferred from admissionStarted at failure time. Fix witness: a case in packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx staging ARM 1 (foreign park with the originator omitted, onAdmissionStarted fired, then a DaemonHttpError) asserting appendLocalUserMessage was NOT called and reportError WAS; removing the guard must turn it red.
中文说明
新增的「提交失败回显」分支把 admissionStarted 读成「daemon 可能已经持有这条消息」,但 onAdmissionStarted 是在 POST 真正发出之前立即触发的(client/daemon/session/actions.ts:1306,早于 session.submitPrompt)。因此在该回调之后到达的确定性拒绝(例如 DaemonHttpError、413)被当成可能的投递:该分支会把暂存的载荷回显到对话记录里,并跳过 reportError —— 用户看到一条 daemon 从未接受的消息,却从未被告知它失败了。
这是回归而非新增表面:在合并基线上,同一段尾部是无条件的 reportError(error, t('queue.queueFailed')),所以每次拒绝都会被上报。可达的是「来源方缺失」这一情形 —— 不带 client id 的提交方(同一 daemon 会话上的终端 CLI,或较旧的 daemon)会让 originatorClientId 为 undefined,于是 park 被写入,随后的拒绝就把失败上报压掉了。显式的外部 originator 已被正确排除,所以漏洞在于省略 originator 时的 fail-open,而不是同客户端提示词。
修复建议:在 park 时就记录该事件是否确定携带了本客户端的 originator id,并要求这一证据(而不仅是 admissionStarted)才允许拒绝走回显而不走上报;或把确定性拒绝(error instanceof DaemonHttpError)排除在回显分支之外 —— 这正是探针实测过的一行改动。同一修复还应重新考虑 restoreQueuedPromptsToEditor::2696 仍然以 !admissionStarted 为条件,所以 413 时草稿也不会被恢复。
约束:省略 originator 在其他所有地方仍必须表示「可能是我们的」(shouldAppendLocalUserMessage 与 started 处理器都依赖这一读法),因此新证据必须在 park 时记录,而不能在失败时从 admissionStarted 推断。修复验证:在 useQueuedPrompts.midTurnReconcile.test.tsx 中新增一个复现 ARM 1 的用例(省略 originator 的外部 park、已触发 onAdmissionStarted、随后抛 DaemonHttpError),断言 appendLocalUserMessage 未被调用且 reportError 被调用;移除该保护后此测试必须变红。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| // A start that parked inside one of these flights was real: that | ||
| // prompt ran, so its echo is owed even though the clear did not take. | ||
| for (const prompt of failedPrompts) { | ||
| replayStartedDuringRemoval(prompt.serverPromptId!); | ||
| } |
There was a problem hiding this comment.
[Critical] R12-2: [certifies-falsely] [new-surface] In clearQueuedPrompts, the new replay echoes a parked start into the transcript and the very next statement rebuilds restoredPrompts from all of failedPrompts unconditionally — so the same message is both displayed as having run and put back into the visible queue, where the user can cancel or edit a prompt the transcript already shows as delivered.
The realistic trigger is a network that does not recover immediately: the DELETE answers removed: false (a start parked inside the flight, so the prompt really did run) and the confirming refresh is still in flight or fails outright. Measured, one message is in the transcript and the row is back as queued with isRemoving: false — for at least one GET round trip, and permanently when the confirming refresh cannot land.
Witness:
witness (5-arm probe; 移出队列失败 = queue.deleteFailed):
ARM A park + removed:false, confirming GET lands -> echo ["cleared message",null,{"promptId":"P"}]; queuedPrompts after: []
ARM A2 park + removed:false, GET FAILS -> echo; queuedPrompts after: [{"text":"cleared message","id":"P","state":"queued","removing":false}] (persists after flush)
ARM A3 park + removed:false, GET still in flight -> echo; queuedPrompts after: [{...same row...}] (persists)
ARM B park + removed:true -> no echo, no row
ARM C no park + removed:false -> no echo, row restored
fix flip (failedPrompts.filter(p => !displayedServerPromptIdsRef.current.has(p.serverPromptId!))
before the .map, re-ran, restored): A2 and A3 -> queuedPrompts after remove settled: [] ; C unchanged (row still restored)
A/B (merge-base hook, same probe): appendLocalUserMessage(new): [] on all five arms — the echo path
does not exist at base, so the duplicate cannot occur there -> new-surface
Restore only the prompts the replay did not echo: filter failedPrompts against displayedServerPromptIdsRef before the .map that rebuilds restoredPrompts, which the flip above measured as fixing A2/A3 while leaving arm C (no park, so nothing echoed) restoring its row exactly as before.
replayStartedDuringRemoval returns without echoing when nothing parked (useQueuedPrompts.ts:946-968), so the filter must key on the echo actually having happened — displayedServerPromptIdsRef is the marker it sets — and not on the removal having failed. Fix witness: a case in packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx staging ARM A3 (park inside the clear's DELETE flight, removed: false, confirming refresh still pending) asserting appendLocalUserMessage was called once AND the row is absent from queuedPrompts; removing the filter must turn it red.
中文说明
在 clearQueuedPrompts 中,新增的 replay 会把一个暂存的 started 事件回显到对话记录里,而紧接着的语句又无条件地用全部 failedPrompts 重建 restoredPrompts —— 于是同一条消息既被显示为已经运行,又被放回可见队列,用户可以去取消或编辑一条对话记录已经显示为「已投递」的提示词。
现实的触发条件是网络没有立刻恢复:DELETE 返回 removed: false(说明有 start 在该请求期间被 park,提示词确实运行过),而确认性的刷新仍在进行中或直接失败。实测结果:一条消息同时在对话记录里,且该行以 queued、isRemoving: false 回到队列 —— 至少持续一个 GET 往返;当确认刷新始终无法落地时则是永久的。
修复建议:只恢复 replay 没有回显过的提示词 —— 在重建 restoredPrompts 的 .map 之前,用 displayedServerPromptIdsRef 过滤 failedPrompts。上面的 flip 实测表明这样可修复 A2/A3,同时保持 ARM C(没有 park,因此没有回显)照旧恢复其行。
约束:replayStartedDuringRemoval 在没有 park 时会直接返回而不回显(useQueuedPrompts.ts:946-968),所以过滤必须以「回显确实发生过」为键(即它设置的 displayedServerPromptIdsRef 标记),而不是以「删除失败」为键。修复验证:在 useQueuedPrompts.midTurnReconcile.test.tsx 中新增复现 ARM A3 的用例(在 clear 的 DELETE 期间 park、返回 removed: false、确认刷新仍挂起),断言 appendLocalUserMessage 被调用一次且 queuedPrompts 中不含该行;移除该过滤后此测试必须变红。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| * still queued, so the clear is applied by the next snapshot that carries it | ||
| * instead of being dropped on the floor. | ||
| */ | ||
| const clearedUnconfirmedPromptIdsRef = useRef<Map<string, number>>(new Map()); |
There was a problem hiding this comment.
[Critical] R12-3: [certifies-falsely] [new-surface] clearedUnconfirmedPromptIdsRef is the sole record of a user cancellation that could not be confirmed, and the owner-change reset wipes it unconditionally — without first issuing the DELETEs it stands for. A cancellation recorded for a later pass is therefore dropped, and the message the user cancelled is re-materialized into the queue and later runs.
The reset's deps are [ownerToken, sessionId, workspaceCwd], so this does not need a session switch: the token is replaced on a same-session workspaceCwd change, and on a non-current guard snapshot. The deferred-clear map exists precisely because a cancellation cannot always be applied at once, so wiping it is the one thing its own doc comment says must not happen.
Witness:
witness (3-arm probe, deterministic split; the deferred clear is recorded via the body's
snapshotState === undefined path at :2271-2277):
ARM 1 control, no owner change -> removePendingPrompt: [["prompt-1",{"sessionId":"session-a"}]] queuedPrompts: []
ARM 2 owner snapshot non-current (same session/cwd) -> removePendingPrompt: [] queuedPrompts: [{"text":"[image]","id":"prompt-1","state":"queued"}]
ARM 3 workspaceCwd changes (same session) -> removePendingPrompt: [] queuedPrompts: [{"text":"[image]","id":"prompt-1","state":"queued"}]
=> 1/1 control applies the cancellation; 2/2 owner-change arms never issue the DELETE and the
cleared message is back in the queue as `queued`
fix flip (deleted :1761, re-ran, restored): arms 2 and 3 both -> removePendingPrompt: [["prompt-1",…]],
queuedPrompts: [] ; arm 1 unchanged
A/B (merge-base hook): submitPrompt calls: 0 on all three arms — the idle-rejection resubmission and
the whole deferred-clear mechanism are added by this PR, so the failing path does not exist at base
Flush before wiping: in the owner-change effect, ahead of removingServerPromptIdsRef.current.clear() and this ref's own reset, issue the pending DELETEs for every id still in clearedUnconfirmedPromptIdsRef (fire-and-forget against the outgoing owner's session), then clear the map.
The flush cannot route through refreshPendingPrompts, which bails out on if (!latestSessionActiveRef.current …) and on the owner token it is about to replace — so it must call the removal action directly for each recorded id. Fix witness: the deferred-clear cases in packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx, extended with an owner-token change between recording the clear and the next snapshot, asserting removePendingPrompt was still called for the cleared id; removing the flush must turn it red.
中文说明
clearedUnconfirmedPromptIdsRef 是「无法确认的用户取消」的唯一记录,而 owner 变更时的重置会无条件清空它 —— 且没有先发出它所代表的 DELETE。于是为后续流程记录的取消被丢弃,用户已取消的消息被重新物化回队列,并在之后真正运行。
该重置的依赖是 [ownerToken, sessionId, workspaceCwd],因此并不需要切换会话:同一会话内 workspaceCwd 变化、或一次非当前的 guard 快照,都会替换 token。这个 map 的存在本身就是因为取消并不总能立刻执行,所以清空它正是其自身注释声明绝不能发生的事。
修复建议:先冲刷再清空 —— 在 owner 变更的 effect 中,在 removingServerPromptIdsRef.current.clear() 和该 ref 自身重置之前,为 clearedUnconfirmedPromptIdsRef 中仍存在的每个 id 发出待处理的 DELETE(针对即将离任的 owner 会话,fire-and-forget),然后再清空该 map。
约束:冲刷不能走 refreshPendingPrompts —— 它会在 if (!latestSessionActiveRef.current …) 处退出,并且依赖它即将被替换掉的 owner token,所以必须对每个记录的 id 直接调用删除动作。修复验证:扩展 useQueuedPrompts.midTurnReconcile.test.tsx 中的 deferred-clear 用例,在「记录取消」与「下一次快照」之间加入一次 owner-token 变更,断言该被取消的 id 仍然调用了 removePendingPrompt;移除冲刷逻辑后此测试必须变红。
— qwen3.8-max via Qwen Code /review (v0.23.3)
| (uncountableAttachmentRow || unboundMatches.length !== 1 | ||
| ? undefined | ||
| : unboundMatches[0]); |
There was a problem hiding this comment.
[Critical] R12-5: [certifies-falsely] The new ambiguity gate makes a text row's start unechoed exactly when its park can never be attributed back to that row. A text row can only become prompt through the last term, which requires unboundMatches.length === 1 — and in that case candidates = prompt ? [] : … is empty, so no soleCandidateRowId is ever stamped. soleCandidateRowId is therefore structurally only ever an attachment-row id, and the failing-body consume that depends on it (park.soleCandidateRowId === localId) can never fire for a text row. The park is written, the settle defers to it, and nothing ever consumes it.
Two of the client's own messages in flight that render identically (two text rows with the same text) is enough: the start is refused at event time, parked, and then lost. The user is additionally told the message failed to queue while the daemon started and completed it.
Witness:
witness: [probe] PROBE-C — two identical text rows both POSTed (submitPrompt calls = 2;
rows [{id:1,'continue',submitting},{id:2,'continue',submitting}]), pending_prompt_started for
prompt-1, then row 1's body rejects AFTER onAdmissionStarted:
INTACT: echoes after start = 0
echoes after failing body = 0 reportError = ["queue.queueFailed"]
echoes after prompt-1 settle = 0 []
echoes after twin body = 1 [["continue",null,{"promptId":"prompt-2"},null]]
echoes after second prompt-1 terminal = 1 [{"promptId":"prompt-2"}]
FIXED : echoes after prompt-1 settle = 1 [["continue",null,{"promptId":"prompt-1"}]]
echoes after second prompt-1 terminal = 2 [{"promptId":"prompt-1"},{"promptId":"prompt-2"}]
-> the daemon started and settled prompt-1; its message never reaches the transcript on any
later terminal event, while the twin echoes only itself
fix constraint, measured: marking EVERY park that named the failing row breaks the two silence tests
this PR adds deliberately (`leaves a suppressed start silent when two in-flight rows render alike`
:15049, `does not hand a text row park to the attachment body that failed beside it` :15372)
=> 2 failed | 233 passed; narrowing the mark to parks whose failing row AND every candidate row are
payload-reproducible (!eventCannotReproducePayload) keeps both green and still fixes the probe
=> 236 passed (236)
Make the park carry its candidate row ids (not only a sole id), and have the failing-submit body mark the park through returnedUnboundPromptIdsRef — which is what disables pendingOwnSubmission at :1020-1028 — so the settle echoes parkedText for a text row whose body has already returned in failure.
The two silence tests this PR adds are argued on payload grounds ("echoing either row's image for prompt-1 would be a guess about which message the daemon ran"), and neither argument reaches the all-text state above, where the settle would echo the daemon's own rendering and there is no payload to guess — so the mark must stay narrow enough to keep both green (useQueuedPrompts.ts:1013-1017 is the deferral it replaces). Fix witness: a case in packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx staging PROBE-C and asserting appendLocalUserMessage was called once with promptId: 'prompt-1'; removing the candidate-row marking must turn it red, and the two existing silence tests at :15049 / :15372 must stay green.
中文说明
新增的歧义判定门使得一个文本行的 start 恰好在其 park 永远无法被归属回该行时不被回显。文本行只能通过最后一个条件项成为 prompt,而该项要求 unboundMatches.length === 1 —— 此时 candidates = prompt ? [] : … 为空,因此永远不会写入 soleCandidateRowId。也就是说,soleCandidateRowId 在结构上只可能是附件行的 id,而依赖它的失败分支消费(park.soleCandidateRowId === localId)对文本行永远不会触发。于是 park 被写入、结算时又让位给它,却没有任何一方去消费它。
只要客户端自己有两条渲染完全相同的在途消息(两条文本相同的文本行)就够了:start 在事件时被拒绝、被 park,然后丢失。用户还会被告知消息入队失败,而 daemon 实际上已经启动并完成了它。
修复建议:让 park 携带其候选行 id 集合(而不仅是唯一 id),并让失败的提交分支通过 returnedUnboundPromptIdsRef 标记该 park —— 这正是 :1020-1028 处关闭 pendingOwnSubmission 的机制 —— 这样一个 body 已经以失败返回的文本行,其结算就能回显 parkedText。
约束:本 PR 新增的两个「保持静默」测试是基于载荷理由的(「为 prompt-1 回显任一行的图片都等于猜测 daemon 运行的是哪条消息」),而这两个理由都不覆盖上面的全文本状态 —— 那里结算回显的是 daemon 自己的渲染,没有载荷需要猜测。因此标记必须足够收窄,以保证这两个测试仍为绿(:1013-1017 是它所替换的让位逻辑)。修复验证:在 useQueuedPrompts.midTurnReconcile.test.tsx 中新增复现 PROBE-C 的用例,断言 appendLocalUserMessage 以 promptId: 'prompt-1' 被调用一次;移除候选行标记后此测试必须变红,且 :15049 / :15372 两个既有静默测试必须保持绿色。
— qwen3.8-max via Qwen Code /review (v0.23.3)
…lder caption Two defects in the same interplay, both found by auditing the previous commit's claims rather than its diff. An echo sourced from a bound payload-complete row records itself only in the displayed marker: it writes no park, because the row is bound, and no `appendedBeforeResponse` entry, because that marker is written for unbound rows alone. A settle clears the displayed marker, so a body whose admission resolves afterwards read the completion as a licence to echo and appended the same message a second time. The settle now records that flag whenever it clears a marker it found set, which covers the bound-row, raw-text and removal-replay routes alike instead of one branch. A row rebuilt from a fully hydrated snapshot carries the daemon's rendering of an attachment-only message as its text, so every row-sourced echo showed a literal `[image]` caption above the picture it stands for. The placeholder is now dropped at the single echo funnel and folded into its empty-payload refusal, so a row that is only a placeholder with no attachments left echoes nothing rather than an empty bubble.
Round 29 of the local audit — dispositions (d16e53f)Round 29 audited the claims of the previous commit rather than its diff, which is why it found what nine diff-focused auditors had walked past. One Critical, pre-existing rather than a regression. An echo sourced from a bound payload-complete row records itself only in the displayed marker — no park, because the row is bound, and no This supersedes an earlier rejection on this PR: a marker write in the started handler's raw-text branch was applied and reverted in round 26 as not load-bearing, and it also rerouted bodies away from the confirming refresh. The write that shipped is at the settle, is load-bearing, and cannot reroute anything, because a settle is terminal. One Suggestion, fixed. A row rebuilt from a fully hydrated snapshot carries the daemon's rendering of an attachment-only message as its text, so every row-sourced echo showed a literal One Suggestion on witness quality, acted on against myself. A caption witness written during this round to pin the replay's Gates at d16e53f: The clean-pass count restarted again: round 29 was not dry. Round 30 runs on d16e53f. |





What this PR does
When a message typed during a running turn is refused because the session has already gone idle, the daemon now says that explicitly instead of returning a bare refusal, and the browser reads that answer as "send this as an ordinary prompt" rather than "the send failed". Before acting on its own view of the queue, the browser confirms against the daemon's authoritative pending-prompt state: it renders the message as sent only when the daemon really took it, keeps the queue row when the daemon queued it behind another turn, and cancels a cleared follow-up only when the daemon confirms that follow-up is still waiting rather than already running. A confirmation that never arrives is treated as proof of nothing, so the row is left alone instead of being removed on a guess. The new response field is documented in the protocol reference and on the published client type, including what its absence means.
Why it's needed
A follow-up typed in the last moment of a turn could vanish from the composer and come back as a "Daemon rejected mid-turn message" error even though nothing had failed: the server's idle verdict reaches the browser before the browser's own streaming state updates, so the client concluded the message was lost and dropped it. The message had never been accepted, so dropping it was the wrong response — it should have been sent as the next prompt.
The opposite error was reachable too. Because the client guessed that an idle refusal meant its resubmission would start immediately, it could render the message as sent and delete the queue row while the daemon still held that message waiting behind another turn — leaving something on screen that cannot be edited or cancelled and later runs as a surprise. And when the user cleared the queue during that window, the client asked the daemon to delete the follow-up without knowing its state; deleting a prompt the daemon has already started cancels that live turn and also swallows the echo of the user's message.
Reviewer Test Plan
How to verify
Run the Web Shell against a daemon and start a turn that takes a few seconds. Type a follow-up and send it so that it lands just as the turn ends — the daemon refuses the mid-turn insert because the session is already idle.
Automated coverage added with this change: the idle-refusal fallback with and without attachments; the image-only placeholder binding, plus the refusals that keep a row from binding to a prompt it does not own (a foreign originator, a captioned rendering, a longer payload, a partially hydrated one, a file-bearing row); two identical follow-ups racing into one queue, including that both still reach the transcript; an explicit insert refused because the session went idle; a cleared follow-up across every removal outcome (daemon still queues it → removed; daemon already runs it → left alone; confirmation unavailable → left alone; DELETE lost → replayed and re-synced); and the echo guarantees for a message whose queue row a stale snapshot consumed, so an admitted message can neither go missing from the transcript nor appear as a bare placeholder. Locally on macOS after merging current
main:packages/web-shell305 files / 7451 tests,packages/acp-bridgebridge.test.ts936 tests,packages/sdk-typescript49 files / 1992 tests,packages/cliserve routes 7453 tests, plusnpm run build,npm run typecheckand repo-wide lint. Two notes on those runs: oneuseComposerCorepaste test fails only under parallel load and passes in isolation, and thepackages/clirun carries nine failures inscheduled-tasks.test.tsandvirtual-subagent-sessions.test.ts— both files are byte-identical tomainand cover scheduled tasks and subagent metrics, which this change does not touch.Evidence (Before & After)
No screenshots — the evidence below is wire and log level.
Before (daemon refusing a mid-turn insert at idle, client dropping the message):
After (same refusal, but stated, and the client keeps the message) — captured against a real
serveprocess built from this PR's commit9add20f0ca: isolatedQWEN_RUNTIME_DIR, random port64272, fresh thread session01b1c049-de2d-4ffc-be3e-6272c027479b, session-bound client idclient_311df798-…:All three refusals matched
{"accepted":false,"reason":"session_idle"}under a strict deep-equality assertion, and the snapshots afterwards confirm the refusal transfers no ownership — nothing queued, nothing promoted, no ordinary prompt created. The check exited 0 (WIRE_IDLE_REASON_PASS); the daemon was then stopped with SIGTERM (daemon stopped, port refusing connections afterwards). The browser half of the fix — resubmitting exactly once, keeping the queue row when the daemon queues the message, and never cancelling a turn that is already running — is covered by the hook-level cases above, not by a driven browser.Tested on
Environment
Local runtime:
npm ci+npm run buildagainst the currentmain, unit and integration suites viavitest. The live daemon capture usednode dist/cli.js serve --http-bridge --port 0 --token <random> --no-web --safe-mode --workspace <repo>with an isolatedQWEN_RUNTIME_DIR.Risk & Scope
Linked Issues
None — this was reported internally and no GitHub issue was filed for it.
中文说明
这个 PR 做了什么
当用户在回合进行中输入的消息因为会话已经进入空闲而被拒绝时,daemon 现在会明确说明原因,而不是只返回一个"未接受";浏览器把这个答复读成"按普通提示词重新发送",而不是"发送失败"。在依据自己对队列的判断行动之前,浏览器会先向 daemon 权威的 pending-prompt 状态确认:只有 daemon 确实接手了才把消息渲染为已发送;daemon 把它排在另一个回合后面时就保留队列行;只有在 daemon 确认该追问仍在排队(而不是已经在运行)时,才去取消用户清掉的追问。确认没有到达时视为"什么也没证明",于是保留该行而不是凭猜测删除。新增的响应字段已写入协议参考文档与对外发布的客户端类型,包括"字段缺失意味着什么"。
为什么需要
在回合即将结束的瞬间发出的追问,可能从输入框消失并以 "Daemon rejected mid-turn message" 报错回来,尽管并没有任何失败:服务端的空闲判定比浏览器自身的流式状态更新更早到达,于是客户端认为消息丢失并把它丢掉。而这条消息其实从未被接受,丢掉它是错误的处理 —— 它本该作为下一个提示词发出去。
反向的错误同样可达。因为客户端猜测"空闲拒绝意味着重投会立刻开始",它可能把消息渲染为已发送并删掉队列行,而 daemon 其实还把它排在另一个回合后面 —— 屏幕上留下一个既不能编辑也不能取消、之后还会突然自己跑起来的消息。另外,当用户在这个窗口内清空队列时,客户端会在不知道状态的情况下要求 daemon 删除该追问;删除一个 daemon 已经启动的提示词会取消那个正在进行的回合,并且连用户消息的回显一起吞掉。
审查测试计划
如何验证
用 Web Shell 连上 daemon,启动一个需要几秒的回合。输入一条追问并让它在回合结束的那一刻发出 —— daemon 会因为会话已空闲而拒绝这次 mid-turn 插入。
本改动新增的自动化覆盖:带附件与不带附件的空闲拒绝回退;纯图片占位文本绑定,以及阻止队列行绑定到不属于自己的 prompt 的各类拒绝(来源客户端不同、渲染带文案、附件数量更多、水合不完整、带文件的行);两条相同追问同时进入一个队列(并验证两条都进入对话记录);因会话转空闲而被拒绝的显式插入;"已清空的追问"在各种删除结果下的行为(daemon 仍在排队 → 删除;daemon 已在运行 → 不动;确认不可用 → 不动;DELETE 丢失 → 重放并重新同步);以及"队列行被陈旧快照消费掉"的消息的回显保证 —— 已被接收的消息既不会从对话记录中消失,也不会以一个占位符气泡出现。本地(macOS,合并当前
main之后):packages/web-shell305 文件 / 7451 测试、packages/acp-bridge的bridge.test.ts936 测试、packages/sdk-typescript49 文件 / 1992 测试、packages/cliserve 路由 7453 测试,加上npm run build、npm run typecheck与全仓 lint。两点说明:一个useComposerCore粘贴测试仅在并行负载下失败、单独运行通过;packages/cli那次运行有九个失败,集中在scheduled-tasks.test.ts与virtual-subagent-sessions.test.ts—— 这两个文件与main逐字节相同,覆盖的是定时任务与子代理指标,本改动没有触碰。证据(前后对比)
没有截图 —— 下面的证据是 wire 与日志层面的。
修改前(daemon 在空闲时拒绝 mid-turn 插入,客户端丢弃消息):
修改后(同样的拒绝,但会说明原因,客户端保留消息)—— 针对本 PR 提交
9add20f0ca构建的真实serve进程抓取:独立QWEN_RUNTIME_DIR、随机端口64272、全新 thread 会话01b1c049-de2d-4ffc-be3e-6272c027479b、绑定会话的 client idclient_311df798-…:三次拒绝都在严格深比较断言下等于
{"accepted":false,"reason":"session_idle"},其后的快照确认拒绝不转移所有权 —— 没有入队、没有提升、也没有创建普通提示词。检查程序退出码 0(WIRE_IDLE_REASON_PASS);随后用 SIGTERM 停止 daemon(日志daemon stopped,端口之后拒绝连接)。修复的浏览器侧行为 —— 只重投一次、daemon 排队时保留队列行、绝不取消已经在运行的回合 —— 由上面的 hook 级用例覆盖,不是通过驱动真实浏览器验证的。测试环境
运行环境
本地:针对当前
main执行npm ci+npm run build,用vitest跑单元与集成套件。真实 daemon 抓取使用node dist/cli.js serve --http-bridge --port 0 --token <random> --no-web --safe-mode --workspace <repo>,并配合独立的QWEN_RUNTIME_DIR。风险与范围
关联 Issue
无 —— 该问题来自内部反馈,没有为它创建 GitHub issue。