Skip to content

fix(web-shell): keep mid-turn messages the daemon rejects at idle - #11289

Open
wenshao wants to merge 35 commits into
mainfrom
fix/web-shell-mid-turn-idle-rejection
Open

fix(web-shell): keep mid-turn messages the daemon rejects at idle#11289
wenshao wants to merge 35 commits into
mainfrom
fix/web-shell-mid-turn-idle-rejection

Conversation

@wenshao

@wenshao wenshao commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

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.

  • Expected: the follow-up is submitted as the next prompt (or stays visible in the queue until it runs). No error toast, no lost text or attachment, and the message appears in the transcript exactly once.
  • Also worth checking: send an image-only follow-up in the same window. The daemon's own summary of an image-only prompt carries no text, so this is the case where the queue row has nothing to match on except the placeholder the daemon renders — the row should still bind to the daemon's prompt rather than being echoed twice or dropped.
  • And: clear the queue while such a follow-up is being resubmitted. A turn the daemon is already running must not be cancelled by that clear.
  • Goal hold and write-blocked sessions still hold the message locally instead of submitting it, and switching sessions during the window must not submit into the newly selected session.

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-shell 305 files / 7451 tests, packages/acp-bridge bridge.test.ts 936 tests, packages/sdk-typescript 49 files / 1992 tests, packages/cli serve routes 7453 tests, plus npm run build, npm run typecheck and repo-wide lint. Two notes on those runs: one useComposerCore paste test fails only under parallel load and passes in isolation, and the packages/cli run carries nine failures in scheduled-tasks.test.ts and virtual-subagent-sessions.test.ts — both files are byte-identical to main and 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):

[mid-turn] session="cd8f0ab6-…" rejected id "verify-idle-text": session idle
POST /session/:id/mid-turn-message → 200 {"accepted":false}
browser: "Daemon rejected mid-turn message" toast, composer content dropped

After (same refusal, but stated, and the client keeps the message) — captured against a real serve process built from this PR's commit 9add20f0ca: isolated QWEN_RUNTIME_DIR, random port 64272, fresh thread session 01b1c049-de2d-4ffc-be3e-6272c027479b, session-bound client id client_311df798-…:

POST /session                                       → 200 {"sessionId":"01b1c049-…","attached":false,"clientId":"client_311df798-…"}
POST /session/:id/mid-turn-message  (text)          → 200 {"accepted":false,"reason":"session_idle"}
POST /session/:id/mid-turn-message  (same id again) → 200 {"accepted":false,"reason":"session_idle"}
POST /session/:id/mid-turn-message  (image only)    → 200 {"accepted":false,"reason":"session_idle"}
GET  /session/:id/mid-turn-messages                 → 200 {"messages":[],"settledMessageIds":[],"promotedMessageIds":[]}
GET  /session/:id/pending-prompts                   → 200 {"pendingPrompts":[]}

daemon stderr:
[mid-turn] session="01b1c049-…" rejected id "pr11289-idle-text": session idle
[mid-turn] session="01b1c049-…" rejected id "pr11289-idle-image": session idle

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

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

Environment

Local runtime: npm ci + npm run build against the current main, unit and integration suites via vitest. The live daemon capture used node dist/cli.js serve --http-bridge --port 0 --token <random> --no-web --safe-mode --workspace <repo> with an isolated QWEN_RUNTIME_DIR.

Risk & Scope

  • Main risk or tradeoff: the client now consults the daemon's pending-prompt state before deciding whether a resubmitted message was started, queued, or cleared, which adds one request per idle-refusal fallback (it replaces the refresh that already ran after every submission, so the count is unchanged on the happy path). Every ambiguous answer — a failed, superseded or skipped confirmation — leaves the queue row in place rather than guessing, so the failure mode is a row that stays visible too long, not a message that disappears.
  • Not validated / out of scope: the daemon's pending-prompt removal is still unconditional, so a removal decided from a confirmation that was accurate when it was read can in principle hit a prompt that started in the meantime. Closing that window needs an opt-in conditional removal on the daemon side, which is a separate change; this PR removes the client-side half (it no longer removes on an unknown state). No two-client browser race was driven end to end, so the behaviour of an image-only follow-up competing with another client's image-only prompt in the same session is reasoned about, not measured.
  • Breaking changes / migration notes: none. The new response field is additive; a daemon that predates it omits it and clients keep their own idle detection alongside it, which is why the fallback still triggers on a refusal with no stated reason.

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 插入。

  • 预期:该追问作为下一个提示词被提交(或保留在队列里直到它运行)。没有错误提示,文本或附件不丢失,消息在转录中只出现一次。
  • 同样值得检查:在同一窗口发送一条只有图片、没有文字的追问。daemon 对纯图片提示词的摘要不带文本,因此这是队列行除了 daemon 渲染的占位文本之外无凭据可匹配的case —— 该行仍应绑定到 daemon 的提示词,而不是被回显两次或丢弃。
  • 以及:在这条追问正在重投时清空队列。daemon 已经在运行的回合不得被这次清空取消。
  • Goal hold 与 write-blocked 会话仍会把消息保留在本地而不提交;在该窗口内切换会话不得把消息投进新选中的会话。

本改动新增的自动化覆盖:带附件与不带附件的空闲拒绝回退;纯图片占位文本绑定,以及阻止队列行绑定到不属于自己的 prompt 的各类拒绝(来源客户端不同、渲染带文案、附件数量更多、水合不完整、带文件的行);两条相同追问同时进入一个队列(并验证两条都进入对话记录);因会话转空闲而被拒绝的显式插入;"已清空的追问"在各种删除结果下的行为(daemon 仍在排队 → 删除;daemon 已在运行 → 不动;确认不可用 → 不动;DELETE 丢失 → 重放并重新同步);以及"队列行被陈旧快照消费掉"的消息的回显保证 —— 已被接收的消息既不会从对话记录中消失,也不会以一个占位符气泡出现。本地(macOS,合并当前 main 之后):packages/web-shell 305 文件 / 7451 测试、packages/acp-bridgebridge.test.ts 936 测试、packages/sdk-typescript 49 文件 / 1992 测试、packages/cli serve 路由 7453 测试,加上 npm run buildnpm run typecheck 与全仓 lint。两点说明:一个 useComposerCore 粘贴测试仅在并行负载下失败、单独运行通过;packages/cli 那次运行有九个失败,集中在 scheduled-tasks.test.tsvirtual-subagent-sessions.test.ts —— 这两个文件与 main 逐字节相同,覆盖的是定时任务与子代理指标,本改动没有触碰。

证据(前后对比)

没有截图 —— 下面的证据是 wire 与日志层面的。

修改前(daemon 在空闲时拒绝 mid-turn 插入,客户端丢弃消息):

[mid-turn] session="cd8f0ab6-…" rejected id "verify-idle-text": session idle
POST /session/:id/mid-turn-message → 200 {"accepted":false}
浏览器:"Daemon rejected mid-turn message" 提示,输入框内容被丢弃

修改后(同样的拒绝,但会说明原因,客户端保留消息)—— 针对本 PR 提交 9add20f0ca 构建的真实 serve 进程抓取:独立 QWEN_RUNTIME_DIR、随机端口 64272、全新 thread 会话 01b1c049-de2d-4ffc-be3e-6272c027479b、绑定会话的 client id client_311df798-…

POST /session                                       → 200 {"sessionId":"01b1c049-…","attached":false,"clientId":"client_311df798-…"}
POST /session/:id/mid-turn-message  (纯文本)         → 200 {"accepted":false,"reason":"session_idle"}
POST /session/:id/mid-turn-message  (同 id 重复)     → 200 {"accepted":false,"reason":"session_idle"}
POST /session/:id/mid-turn-message  (仅图片)         → 200 {"accepted":false,"reason":"session_idle"}
GET  /session/:id/mid-turn-messages                 → 200 {"messages":[],"settledMessageIds":[],"promotedMessageIds":[]}
GET  /session/:id/pending-prompts                   → 200 {"pendingPrompts":[]}

daemon stderr:
[mid-turn] session="01b1c049-…" rejected id "pr11289-idle-text": session idle
[mid-turn] session="01b1c049-…" rejected id "pr11289-idle-image": session idle

三次拒绝都在严格深比较断言下等于 {"accepted":false,"reason":"session_idle"},其后的快照确认拒绝不转移所有权 —— 没有入队、没有提升、也没有创建普通提示词。检查程序退出码 0(WIRE_IDLE_REASON_PASS);随后用 SIGTERM 停止 daemon(日志 daemon stopped,端口之后拒绝连接)。修复的浏览器侧行为 —— 只重投一次、daemon 排队时保留队列行、绝不取消已经在运行的回合 —— 由上面的 hook 级用例覆盖,不是通过驱动真实浏览器验证的。

测试环境

OS Status
🍏 macOS
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

运行环境

本地:针对当前 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

风险与范围

  • 主要风险或取舍:客户端现在会先查询 daemon 的 pending-prompt 状态,再决定重投的消息是已开始、已排队还是已清空,这为每次空闲拒绝回退增加一次请求(它替代了原本每次提交后都会跑的那次刷新,因此正常路径上的请求数不变)。所有含糊的答复 —— 确认失败、被顶替或被跳过 —— 都会保留队列行而不是猜测,所以失效模式是"某行多显示了一会儿",而不是"消息消失"。
  • 未验证 / 范围之外:daemon 的 pending-prompt 删除仍是无条件的,因此一个在读取时准确的确认,仍有可能落到一个在此期间已启动的提示词上。要关掉这个窗口需要 daemon 侧提供可选的条件删除,那是另一个改动;本 PR 去掉的是客户端这一半(不再在状态未知时删除)。也没有端到端驱动双客户端浏览器竞态,所以"纯图片追问与另一客户端的纯图片提示词在同一会话竞争"的行为是推理得出的,不是实测的。
  • 破坏性变更 / 迁移说明:无。新增响应字段是增量式的;早于该字段的 daemon 会省略它,客户端会同时保留自己的空闲判定 —— 这也是为什么一个没有说明原因的拒绝仍会触发回退。

关联 Issue

无 —— 该问题来自内部反馈,没有为它创建 GitHub issue。

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.
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

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

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

@wenshao

wenshao commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Verification report — 9add20f0ca

Everything below was run locally on macOS against this PR's commit, after a clean npm ci on current main. No source or test file was modified to obtain a green result.

Automated gates

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_idle as a property of session state rather than of the refusal's cause, which would have led an integrator to read a missing reason as "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 inconclusive on 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 lint aborts 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/cli has 8 pre-existing failing test files on this machine (user-config and absolute-path leakage: provider list, ~/.qwen path 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 buildnpm run bundlenpm run typecheck 均 exit 0;packages/web-shell 全套件 279 文件 / 6420 测试通过;packages/acp-bridge 35 文件 / 1959 通过;packages/sdk-typescript daemon client 415 通过;packages/clisrc/serve/server.test.ts 1226 通过;所有改动文件的 eslintprettier --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 通过。

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head d16e53f, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 12 scenario(s).

Qwen Code · serve A/B

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

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

Screenshots · before / after

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

  • packages/web-shell/client/components/QueuedPromptDisplay.tsx

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

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

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! This is a re-run at 9f8993e6, so it replaces my 2026-09-07 pass — which predates six review rounds, an autofix takeover, and a lot of new evidence.

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 serve process, and the 2026-09-09 Linux run drives the shipped bundle in real Chromium against a real daemon: the failure reproduces on merge base cfb173ec46 and is gone on this branch. The negative control is the part that matters — on base the toast fires, the composer content is dropped, and the fake model logs one request instead of two. The 2×2 cross matrix (base browser + PR daemon ✗, PR browser + base daemon ✗, PR + PR ✓) also shows neither half is decorative. Stage 1b passes on evidence.

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 reason field to the mid-turn-message response and to the published SDK type — a public-contract addition, but additive, documented in the protocol reference, and the cross matrix is the compatibility evidence. Noting it rather than escalating; the author is an admin maintainer.

Size: cross-package (acp-bridge, cli, sdk-typescript, web-shell), so it lands in the core-infra breadth clause — but it is maintainer-authored, which exempts it from the two-tier gate, and the title is fix rather than refactor, so no hard block would apply either way.

lines
production 618useQueuedPrompts.ts 585, serve/routes/session.ts 15, sdk-typescript/src/daemon/types.ts 13, bridge.ts / bridgeTypes.ts / QueuedPromptDisplay.tsx 2 each
test 2984 — 2948 of them in one new reconcile spec
docs 2

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 (:1702, :1779, :1819), each consulting a different subset of six refs (settled…, displayed…, pendingStarted…, completed…, removing…, syncClaimed…). Two of the four standing Criticals are literally "this site forgot a term its sibling has". Separately, the sync path still binds rows by rendered text (matchesUnboundSubmittingRow, four call sites) against a hardcoded '[image]' placeholder (:382) — which is where R1-1, R2-1, R4-1, R4-3 and R4-4 all originated. My independent proposal is in Stage 2; the short version is that one shared "is this prompt already owned, started, or settled" predicate would have prevented R5-2 and R6-2 outright, and is what the root-cause-triage convergence signal was pointing at.

Risk: no elevated risk signals — none of the changed files match the high-risk path list.

Moving on to code review. 🔍

中文说明

感谢贡献!本次是在 9f8993e6 上的重跑,因此取代我 2026-09-07 那一轮——那一轮早于六轮评审、autofix 接管以及大量新证据。

模板完整 ✓ —— 所有必需标题都在,包含完整的中文翻译。

问题: 已观测到的缺陷,不是理论性加固。虽然没有创建 GitHub issue("内部反馈"),但证据比多数关联 issue 更扎实。PR 描述里有针对真实 serve 进程的 wire 层前后对比;2026-09-09 的 Linux 验证 用真实 Chromium 驱动真实 daemon 上的发布产物:该故障在 merge base cfb173ec46 上复现,在本分支上消失。关键是反向对照——base 臂上会弹出提示、输入框内容被丢弃、假模型只记录到一次请求而不是两次。2×2 交叉矩阵(base 浏览器 + PR daemon ✗、PR 浏览器 + base daemon ✗、PR + PR ✓)也说明两半都不是装饰性的。Stage 1b 依据证据通过。

方向: 对齐。回合末尾输入的追问无声消失,或队列行既不能编辑也不能取消、之后还会突然自己跑起来,这是 Web Shell 中用户可见的数据丢失。它确实给 mid-turn-message 响应和对外发布的 SDK 类型新增了 reason 字段——属于公共契约新增,但是增量式的、已写入协议参考文档,交叉矩阵就是兼容性证据。此处只做记录而不升级;作者是 admin 维护者。

规模: 跨包改动(acp-bridgeclisdk-typescriptweb-shell),因此落入核心基础设施的"广度"条款——但本 PR 由维护者提交,免于两级门禁;且标题是 fix 而非 refactor,无论如何都不触发硬性拦截。

生产代码 618 行(useQueuedPrompts.ts 585、serve/routes/session.ts 15、sdk-typescript/src/daemon/types.ts 13、bridge.ts / bridgeTypes.ts / QueuedPromptDisplay.tsx 各 2),测试 2984 行(其中 2948 行集中在一个新的 reconcile spec),文档 2 行。低于 1000 行的大 PR 建议线,因此不以规模为由建议拆分。

方案: 这是我在代码审查前唯一想推动的一点,而六轮评审让它变得具体而非臆测。第 4–6 轮的每一条 Critical 都落在同一套新机制里——确认快照及其判定记账。现在一个函数内有三个判定点(:1702:1779:1819),各自查询六个 ref(settled…displayed…pendingStarted…completed…removing…syncClaimed…)中不同的子集。四条未决 Critical 里有两条本质上就是"这个点漏掉了兄弟点有的条件"。另外,sync 路径仍然按渲染文本绑定行(matchesUnboundSubmittingRow,四个调用点),并依赖硬编码的 '[image]' 占位文本(:382)——R1-1、R2-1、R4-1、R4-3、R4-4 都源于此。我的独立方案见 Stage 2;简版是:一个共享的"该 prompt 是否已被认领/已开始/已结算"判定就能直接避免 R5-2 与 R6-2,这也正是 root-cause-triage 收敛信号所指向的根因。

风险: 无升级风险信号——改动文件均未命中高风险路径清单。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 9f8993e6a4ec8eba850f96113fad25a494379085 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Code review

My 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 session_idle, resubmit through the ordinary prompt path and let the existing post-submit refresh settle the row — do not stand up a second verdict system beside it. Bind strictly by the promptId the daemon returns from POST /prompt; never by rendered text. That one rule deletes matchesUnboundSubmittingRow and the '[image]' placeholder entirely, which is where R1-1, R2-1, R4-1, R4-3 and R4-4 came from. (3) For "the user cleared the row during the window": make the removal conditional on the daemon (refuse the DELETE unless the prompt is still queued) instead of having the client infer permission from a snapshot it read earlier.

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 head

I read each of these against the file as it stands at 9f8993e6, not against the diff or the finding titles. All four reproduce as described, and @qqqys reached the same four independently in their Critical-only pass five minutes after this run started.

  • R6-1 (:899-910) — the deferred continuation drops the entry guards. Confirmed: the join branch checks latest.sessionId === targetSessionId, but the fall-through at :909 calls dispatchRefresh() with no session and no connected re-check. The entry guards at :836-839 ran synchronously up to DEFAULT_FETCH_TIMEOUT_MS earlier, runRefresh's post-await check (:853-858) re-tests owner token and session but never connected, and there is no connectedRef in the file. Because refreshRequestSeqRef is bumped at dispatch time (:842), the stale dispatch for the abandoned session supersedes the switched-to session's in-flight GET, which returns { status: 'superseded' } at :851-852 and never reaches syncServerQueuedPrompts. The panel for the session the user is now looking at stays unsynced. This is a regression — before this PR every refresh dispatched synchronously behind live guards.
  • R5-2 (:1702-1711) — the cleared-row DELETE licence omits two start markers. Confirmed, and the diff argues against itself: the sibling arm 110 lines below defines startedSinceSnapshot as displayed || pendingStarted || completed (:1819-1823) under the comment "its Remove would abort the turn", while queuedInSnapshot here consults only refresh.status, settledServerPromptIdsRef and the snapshot state, with displayedServerPromptIdsRef added by the outer gate. A start the client already recorded at :1501 therefore does not stop removePendingPrompt against a prompt the daemon is running.
  • R6-2 (:1778-1786) — the failed-GET arm has no settle veto and re-reads a pre-await local. Confirmed: it is the only one of the three verdict sites without a settledServerPromptIdsRef term, and if (!localMessageAppended) at :1784 reads a local captured before the await at :1673. The premise holds too — hideSettledServerPrompt clears the echo guard rather than strengthening it (:614-615: displayedServerPromptIdsRef.current.delete(promptId) then settledServerPromptIdsRef.current.add(promptId)), so start echo → settle → failed GET appends the user's message to the transcript a second time.
  • R6-4 (:1922-1938) — false "queue failed" for an admitted submission. Confirmed: syncClaimed lets the body proceed even when admissionStarted is true, the editor restore is correctly gated on !admissionStarted (:1935-1937), but reportError(error, t('queue.queueFailed')) at :1938 is unconditional. The success path at :1688 reads the same syncClaimed fact as proof of admission — one gate, two opposite meanings.

Two more from earlier rounds are still unresolved and unchanged in shape: R5-1 (:1664-1666 gates the confirmation branch on latestSessionActiveRef.current, assigned in the render body at :639) and R2-3 (:894 still shares any in-flight GET when notBefore defaults to 0 at :834). That is the 7 standing Criticals the autofix convergence break counted.

Where the maintainer's product-level run actually lands

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

  • R5-1 — I accept the rebuttal, with a correction. It reproduces identically on base, so it is a pre-existing property of main, not a regression. But it also means the new confirmation branch does not close the second failure the PR description narrates, because the branch is gated on the very mirror that lags. Non-blocking as a regression; the description overclaims and should say so.
  • R6-4 — the negative does not reach the finding. The scenarios driven were "delaying the DELETE by 3–6 s" and "clearing the row during the confirming window". R6-4's trigger is a POST that rejects after onAdmissionStarted — timeout, connection reset, daemon restart, 5xx. No transport failure after admission was induced, so the run never entered that branch. The reporting round carries a measured A/B probe (BASE reportError calls= [] vs PR [["Error: transport lost after admission", "queue.queueFailed"]], steps 1–2 byte-identical) and a tested fix that keeps 220 tests green. Stands.
  • R2-3 — same shape. Stalling every pending-prompts GET exercises the fenced callers; R2-3 is about the 12 of 15 call sites that pass no fence and inherit notBefore = 0. Not reached.
  • R6-1, R5-2, R6-2 — not addressed by the product run at all. R6-1 needs a session switch inside a parked-GET window, R6-2 a start echo → settle → failed GET ordering, R5-2 a second unbound submitting row suppressing the echo. None appears in the described scenarios.

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-blocking

The run's own observation 1 is worth acting on: IMAGE_ONLY_PROMPT_TEXT at :382 hardcodes '[image]', duplicating the daemon's producer (bridge.ts:2473, also spelled at :10147 and :13489). A shared exported constant makes the identity check impossible to break silently. Per AGENTS.md's 5-round rule, this and every other Suggestion should go to a follow-up, not this PR.

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
Loading
Files changed (12)
File What changed
docs/developers/qwen-serve-protocol.md One line documenting the new refusal reason. R4-2 asked whether the sentence promises more than the code keeps; that thread is resolved.
packages/acp-bridge/src/bridge.ts One line on the refusal path. Small, and the cross matrix shows it is load-bearing.
packages/acp-bridge/src/bridgeTypes.ts One line carrying the reason through the bridge type.
packages/acp-bridge/src/bridge.test.ts Two lines updated for the new field.
packages/cli/src/serve/routes/session.ts The route states the idle verdict instead of a bare refusal. This is the daemon half of the fix.
packages/cli/src/serve/server.test.ts Coverage for the new response shape.
packages/sdk-typescript/src/daemon/types.ts The published client type gains the optional reason, with JSDoc on what its absence means. Public-contract addition, additive.
packages/sdk-typescript/test/unit/DaemonClient.test.ts Carries the new field across the client hop — this is the seam R1-7 said was untested.
packages/web-shell/client/components/QueuedPromptDisplay.tsx One line, rendering the retained row.
packages/web-shell/client/daemon/session/actions.test.ts Coverage for the enqueue hop.
packages/web-shell/client/hooks/useQueuedPrompts.ts The whole client half — 510 added, 75 removed. All four standing Criticals and both older ones live here.
packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx 2948 new lines of hook-level reconcile coverage. Extensive, but it does not pin the four orderings above.

Test evidence

This 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 9f8993e6 are complete: 25 success, 71 skipped, 0 failed, 0 pending, and the combined status rollup is success. Nothing red, so there is no failing-job log to quote. CI is not what blocks this PR.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Lint & Static (ubuntu-latest, Node 22.x) success
Integration Tests (no-AK, No Sandbox) success
Serve A/B (ubuntu-latest, Node 22.x) success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
Capture web-shell visuals (ubuntu-latest, Node 22.x) success
TUI parity snapshots (ink vs opentui) success
OpenTUI no-flicker gate success
Real daemon E2E / Java 11 success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
Live Host (macos-latest) success
Test (windows-latest, Node 22.x) skipped
Test (macos-latest, Node 22.x) skipped
Integration Tests (CLI, No Sandbox) skipped
…and 10 further bot-orchestration checks success or skipped

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: @qwen-code /verify — specifically that R6-1's deferred dispatch really supersedes the switched-to session's GET (park a pending-prompts flight, put a notBefore caller on the wait path, switch sessions, settle the parked flight, assert no further GET for the abandoned session and that the new session's rows still materialize), and that R6-2's start → settle → failed-GET ordering yields two appendLocalUserMessage calls rather than one. Neither is observable from the diff, and this PR's suite passes with both defects present. A run is already in flight from this triage invocation (live progress); its report posts to the verification comment. @qwen-code /tmux is the wrong lane here — this is a browser surface, not a TUI one. The author has write access, so both lanes are available unsponsored.

中文说明

代码审查

我的独立方案(在读 diff 之前,仅依据标题与"为什么需要"写出)。 三步。(1) daemon 侧:在 wire 上说明拒绝原因——成本很低,且交叉矩阵证明它是必需的,这一点我会和本 PR 完全一致。(2) 客户端侧:收到 session_idle 后走普通 prompt 路径重投,并让既有的提交后刷新去结算该行——不要在它旁边再搭一套判定系统。严格按 daemon 从 POST /prompt 返回的 promptId 绑定,绝不按渲染文本绑定。仅这一条就能彻底删掉 matchesUnboundSubmittingRow'[image]' 占位文本,而 R1-1、R2-1、R4-1、R4-3、R4-4 都源于此处。(3) 针对"用户在窗口内清空了该行":把删除条件放到 daemon 侧(除非该 prompt 仍为 queued 否则拒绝 DELETE),而不是让客户端依据自己早前读到的快照去推断许可。

与 diff 对比:本 PR 在第 (1) 步与我一致,在 (2)(3) 上更激进——它让客户端充当仲裁者,用六个 ref 支撑三个判定点。它明确把 (3) 划到范围之外("daemon 的 pending-prompt 删除仍是无条件的……本 PR 去掉的是客户端这一半")。这个选择正是成本所在:客户端在对一个本可以直接询问的状态所有者做分布式状态推断,而六轮评审每轮都找到一种新的时序使该推断出错。在真正要紧的维度上,我的方案明显更简单——与其让它以"第七个 ref"的形式再冒出来,不如现在点明。

阻塞性 Critical —— 四条,均由我在本 head 上核实

我是对着 9f8993e6 上该文件的实际代码逐条阅读的,不是依据 diff 或结论标题。四条都如描述成立;@qqqys 在本次运行开始五分钟后,在其 Critical-only 复查中独立得到了同样四条。

  • R6-1(:899-910)—— 延迟续延丢弃了入口守卫。 已确认:join 分支检查 latest.sessionId === targetSessionId,但 :909 的兜底直接调用 dispatchRefresh(),既未重新检查 session 也未检查 connected:836-839 的入口守卫是同步执行的,最晚可能在 DEFAULT_FETCH_TIMEOUT_MS 之前;runRefresh 在 await 之后的检查(:853-858)只重测 owner token 与 session,从不重测 connected,而文件中没有 connectedRef。由于 refreshRequestSeqRef派发时自增(:842),为已放弃会话发起的过期派发会顶掉被切换到的会话正在进行的 GET,使其在 :851-852 返回 { status: 'superseded' },永远到不了 syncServerQueuedPrompts。用户正在看的会话面板因此不同步。这是回归——本 PR 之前每次刷新都在有效守卫后同步派发。
  • R5-2(:1702-1711)—— 清空行的 DELETE 许可漏掉两个"已开始"标记。 已确认,而且 diff 自相矛盾:下方 110 行的兄弟分支把 startedSinceSnapshot 定义为 displayed || pendingStarted || completed:1819-1823),注释写着"its Remove would abort the turn";而这里的 queuedInSnapshot 只查询 refresh.statussettledServerPromptIdsRef 与快照状态,外层门只额外加了 displayedServerPromptIdsRef。因此客户端在 :1501 已经记录过的启动并不能阻止对 daemon 正在运行的 prompt 调用 removePendingPrompt
  • R6-2(:1778-1786)—— GET 失败分支没有结算否决,且重读了 await 之前的局部变量。 已确认:它是三个判定点中唯一没有 settledServerPromptIdsRef 条件的,且 :1784if (!localMessageAppended) 读取的是 :1673 await 之前捕获的局部变量。前提同样成立——hideSettledServerPrompt清除回显守卫而非加强它(:614-615:先 displayedServerPromptIdsRef.current.delete(promptId),再 settledServerPromptIdsRef.current.add(promptId)),所以"启动回显 → 结算 → GET 失败"会把用户消息第二次追加进对话记录。
  • R6-4(:1922-1938)—— 对已被接纳的提交报出虚假的入队失败。 已确认:即使 admissionStarted 为真,syncClaimed 也会让 body 继续;编辑器恢复被正确地限定在 !admissionStarted:1935-1937),但 :1938reportError(error, t('queue.queueFailed')) 是无条件的。而 :1688 的成功路径把同一个 syncClaimed 读作已被接纳的证明——一个门,两种相反含义。

另有两条早前轮次的 Critical 仍未决且形态未变:R5-1:1664-1666 把确认分支挂在 latestSessionActiveRef.current 上,而该 ref 在渲染体 :639 赋值)与 R2-3:894notBefore 默认为 0:834)时仍会共享任意在途 GET)。这正是 autofix 收敛中断所统计的 7 条未决 Critical。

维护者的产品级验证实际覆盖到哪里

Linux 浏览器验证 是很扎实的验证工作。我想精确说明它触及了哪些结论,而不是笼统地采信或否定。

  • R5-1 —— 我接受这个反驳,但需一处更正。 它在 base 上以完全相同的方式复现,所以是 main 的既存性质,不是回归。但这也意味着新的确认分支并没有关掉 PR 描述所叙述的第二个故障,因为该分支正挂在那个滞后的镜像上。作为回归它不阻塞;但 PR 描述的说法过强,应当更正。
  • R6-4 —— 该否定结论没有触及这条 finding。 驱动的场景是"把 DELETE 延迟 3–6 秒"与"在确认窗口内清空该行"。R6-4 的触发条件是 POST 在 onAdmissionStarted 之后失败——超时、连接重置、daemon 重启、5xx。验证中没有诱发接纳之后的传输失败,因此从未进入该分支。评审轮次给出了实测 A/B 探针(BASE reportError calls= [] 对 PR [["Error: transport lost after admission", "queue.queueFailed"]],step 1–2 逐字节相同)以及一个保持 220 个测试全绿的实测修复。该结论成立。
  • R2-3 —— 形态相同。 让所有 pending-prompts GET 停顿,走的是已加栅栏的调用方;R2-3 说的是 15 个调用点中有 12 个不传栅栏、因而继承 notBefore = 0。未被触及。
  • R6-1、R5-2、R6-2 —— 产品级验证完全没有涉及。 R6-1 需要在挂起的 GET 窗口内切换会话,R6-2 需要"启动回显 → 结算 → GET 失败"的时序,R5-2 需要第二条未绑定的 submitting 行抑制回显。这些都没有出现在所述场景中。

需要说明的是,这份报告自己也讲清楚了:"这是来自真实驱动的诚实否定结论,不是不存在证明……仍可能存在我没有构造出的时序。" 这个表述是对的,也正因此我没有把"可以合入"的结论当作这些 finding 已关闭。

非阻塞

报告自己的观察 1 值得处理::382IMAGE_ONLY_PROMPT_TEXT 硬编码 '[image]',与 daemon 侧的产生者重复(bridge.ts:2473,另在 :10147:13489 出现)。共享一个导出常量可以让这个身份判断不可能被静默破坏。按 AGENTS.md 的 5 轮规则,这一条以及所有其他 Suggestion 都应转入后续 issue/PR,而不是留在本 PR。

测试证据

本次为无人值守 CI 运行,因此我没有构建或执行任何 PR 派生代码——下面的证据是 PR 自己的 CI,通过 API 针对被审查提交读取。

9f8993e6 上全部 96 个 check-run 均已完成:25 成功、71 跳过、0 失败、0 进行中,组合状态汇总为 success。没有红色项,因此没有失败日志可引。CI 不是本 PR 的阻塞原因。

关于跳过项有两点覆盖说明(PR 自己的 "Tested on" 表格在 CI 未覆盖的平台上依赖本地运行):单元矩阵只在 ubuntu / Node 22 上跑过,CLI 集成通道被跳过(第 5 轮评审也披露了同样情况)。因此跨平台信心来自维护者本地的 macOS 与 Linux 运行,那是作者的声明,不是我一并复跑的证据。

未验证:R6-1、R5-2、R6-2、R6-4 在运行态产品上的表现——无论 CI 还是本地,都没有构造出这些时序。Linux 报告覆盖的是主缺陷、队列行保留以及"不取消正在运行的回合"这一性质,并且明确说明未决 Critical 不是它的目标。

沙箱验证可以定论这一点:@qwen-code /verify —— 具体是验证 R6-1 的延迟派发是否真的会顶掉被切换到的会话的 GET(挂起一个 pending-prompts 请求,让一个带 notBefore 的调用方进入等待路径,切换会话,结算被挂起的请求,断言没有再为已放弃会话发起 GET、且新会话的行仍能物化),以及 R6-2 的"启动 → 结算 → GET 失败"时序是否产生两次 appendLocalUserMessage 而非一次。这两点都无法从 diff 观察得到,而本 PR 的测试套件在两个缺陷都存在的情况下依然全绿。本次 triage 调用已触发一次运行(实时进度),其报告会发布在验证评论中。@qwen-code /tmux 在此不适用——这是浏览器界面,不是 TUI。作者有写权限,因此两条通道都无需赞助即可使用。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 9f8993e6a4ec8eba850f96113fad25a494379085 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

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 useQueuedPrompts.ts at 9f8993e6 rather than trusting the finding titles, and @qqqys's Critical-only pass reached the same four independently. Two older ones (R5-1, R2-3) are unchanged in shape.

The maintainer's Linux run does not close them, and to its credit it says so. It rebuts R5-1 convincingly — pre-existing on main, so not a regression, though it also means the PR's second narrated failure isn't closed and the description overclaims. Its negatives on R6-4 and R2-3 don't reach those findings: R6-4 needs a POST that rejects after onAdmissionStarted, and R2-3 is about the 12 of 15 call sites that pass no fence — neither ordering was constructed. R6-1, R5-2 and R6-2 aren't addressed at all.

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 root-cause-triage convergence break flagged. That is not bad luck; it is what happens when three verdict sites each consult a different subset of six refs and every fix is another term at one site. My Stage 2 proposal is the way out and it shrinks the diff: one shared "already owned, started, or settled" predicate called at all three sites removes R5-2 and R6-2 by construction instead of by guard, and binding strictly by the daemon's promptId retires the rendered-text matcher that produced five Criticals across rounds 1–4. Asking the daemon to make removal conditional is the larger change the PR scoped out, and it is the one that would delete this class of bug rather than police it — worth a follow-up issue either way.

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 CHANGES_REQUESTED — the review posted against this same commit on 2026-09-09T10:04:08Z already gates the PR, and @qqqys has declined to approve at this head. Per AGENTS.md's five-round rule this PR is past the point where review rounds should widen it: land Critical fixes only — R6-1, R5-2, R6-2, R6-4, plus R5-1 and R2-3 or an explicit note on each thread recording why they're out of scope with a linked follow-up issue. Every Suggestion, including the '[image]' constant duplication, should go to that follow-up rather than this diff. Then push and re-request review; I'll re-run on the new head.

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 的回归。我是对着 9f8993e6 上的 useQueuedPrompts.ts 逐条阅读的,而不是采信结论标题;@qqqys 的 Critical-only 复查也独立得到了同样四条。另有两条早前的(R5-1、R2-3)形态未变。

维护者的 Linux 验证没有关闭它们,而且报告自己也这么说。它对 R5-1 的反驳是可信的——在 main 上既存,因此不是回归;但这也意味着 PR 叙述的第二个故障并未被关闭,描述的说法过强。它对 R6-4 与 R2-3 的否定结论没有触及这两条 finding:R6-4 需要 POST 在 onAdmissionStarted 之后失败,R2-3 说的是 15 个调用点中有 12 个不传栅栏——这两种时序都没有被构造出来。R6-1、R5-2、R6-2 则完全未涉及。

我是否被数量磨软了?我检查过这一点,因为六轮评审容易如此。答案是没有——即使这是第一轮 PR、带着这四条 finding,我也会给出同样结论;而第 6 轮的探针是带实测修复的 A/B 双臂测量,不是臆测。

真正关键的是"模式"问题。第 2 到第 6 轮每次都关闭了一个已报告的输入,又在同一个簇里打开了新 finding,这正是 root-cause-triage 收敛中断所标记的。这不是运气差;当三个判定点各自查询六个 ref 中不同的子集、而每次修复都是在某一个点再加一个条件时,必然如此。我在 Stage 2 的方案就是出路,而且它会缩小 diff:一个在三个点共用的"是否已被认领/已开始/已结算"判定,能从构造上(而不是靠守卫)消除 R5-2 与 R6-2;严格按 daemon 返回的 promptId 绑定,则能让那个在第 1–4 轮产生了五条 Critical 的渲染文本匹配器退役。至于让 daemon 侧把删除变成有条件——这是 PR 划出范围之外的较大改动,也是唯一能删除这一类缺陷而不是看管它的改动;无论如何都值得开一个后续 issue。

关于拆分:我考虑过建议 daemon 那一半单独合入,因为它只有 30 行、增量式、有文档、且被独立证明是必需的。但交叉矩阵显示它单独修不好任何问题,所以拆分带不来用户可见的进展,只换来更干净的历史。在这里不值得。

动作: 我不批准,也不会再叠加第二条 CHANGES_REQUESTED——2026-09-09T10:04:08Z 针对同一提交提交的评审已经在门禁住本 PR,@qqqys 也已在本 head 上拒绝批准。按 AGENTS.md 的五轮规则,本 PR 已过了评审轮次应当继续扩大 diff 的阶段:只合入 Critical 修复——R6-1、R5-2、R6-2、R6-4,外加 R5-1 与 R2-3(或在各自 thread 上明确记录为何划出范围并关联后续 issue)。所有 Suggestion,包括 '[image]' 常量重复,都应转入该后续 issue 而不是留在本 diff。然后推送并重新请求评审;我会在新 head 上重跑。

有一点想对 @wenshao 直说:你为 R5-1 建立的残余风险清单——窗口内出现第二次接纳所有活跃度信号同时迟到,在生产中对应那一瞬间连接卡顿——正是恰当的披露形式,也是我能轻松接受那条反驳的原因。上面四条需要同样的处理,或者被修复;它们无法承受的,是一个从未进入其分支的驱动所给出的否定结论。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 9f8993e6a4ec8eba850f96113fad25a494379085 · re-run with @qwen-code /triage

Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
@wenshao

wenshao commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover from 2

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

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This 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/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本窗口轮次计数从 2 起算(即本 PR 托管前已进行的评审轮数),因此再经过 3 个产生改动的轮次即进入 Critical-only,而非重新计满 5 轮。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-dev-bot

qwen-code-dev-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

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

中文说明

AutoFix 第 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.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Review round addressed — fc9109b85e

One commit on top of 9add20f0ca, touching only packages/web-shell: the hook, its mid-turn reconcile suite, and the session actions suite. No base merge was needed (--conflict false).

R1-1 — Critical: idle-fallback confirmation bound by rendered text instead of the daemon's promptId (rc:3949624084, resolved)

Reproduced first: the new case binds two identical image idle fallbacks by daemon prompt id (placed beside drops the local duplicate when two identical idle fallbacks queue, both enqueues carrying an image, both snapshot prompts carrying that image content) fails on the pre-round code with one row echoed via appendLocalUserMessage({promptId:'prompt-2'}) and only one row surviving — the message echoed as sent while the daemon still holds it queued. Fixed exactly at the named spot: inside the resubmittedAfterIdleRejection confirmation block, after the refresh.status !== 'refreshed' branch, when the confirming sync bound nothing (bound === undefined) but the snapshot still lists result.promptId as queued, the local row is bound by the authoritative id (serverPromptId + serverState: 'queued') instead of falling through to the echo-and-drop path. The text matcher, the '[image]' placeholder clause, and the materialization path are untouched — the single-attachment and placeholder cases still bind through them and keep their existing tests. Acceptance mutation run: deleting the new branch turns exactly the new test red (1 failed / 119 skipped), suite otherwise green.

R1-14 — cleared prompt resurrected for the DELETE round trip (rc:3949624110, resolved)

Implemented the suggested variant verbatim: keep the late removingServerPromptIdsRef registration and, synchronously with it (no await in between), drop the row the confirming sync just materialized for result.promptId — the same filter the removal success handler already applies — before firing the DELETE. The test at useQueuedPrompts.midTurnReconcile.test.tsx (removes a delayed idle fallback cleared before its snapshot arrives) now drives removePendingPrompt through the file's deferred<T>() helper and asserts queuedPrompts is [] after the confirming snapshot resolves and before the DELETE resolves. Acceptance mutation run: deleting the drop turns that assertion red ([{id:2,…}] vs []).

R1-6 — retention-filter guard had no test (rc:3949624098, disposition changed on evidence — thread left open)

With the R1-14 drop in place, the guard the finding asks to pin became unreachable: the only unflagged rows that could ever reach it were the ones the confirming sync materialized for a just-registered removal id (the two pre-existing add sites flag their rows first, so they are kept by the earlier filter branch), and those rows are now dropped in the same synchronous block as the registration — no sync can interleave, because every other refresh syncs only after its own getPendingPrompts await, which cannot resolve inside that block. Per the rule that an unwitnessable guard must be pinned or dropped, the two lines were dropped (net −2). The resurrection concern itself is now pinned: the extended test publishes a foreign-originator pending_prompt_started mid-removal whose refresh still lists the prompt as queued and asserts the queue stays empty, and that assertion is mutation-verified against the guard that actually carries the behavior — neutralizing the pre-existing sync-loop removingServerPromptIdsRef check turns it red (a fresh {id:3,…} row materializes). Posted as a reply on the thread with this evidence rather than silently resolving, since the requested acceptance mutation (delete the filter-guard lines) no longer exists to run.

R1-7 — no test carries reason across the enqueueMidTurnMessage hop (rc:3949624144, resolved)

Added forwards a daemon idle rejection across the session hop in packages/web-shell/client/daemon/session/actions.test.ts: the session client resolves { accepted: false, reason: 'session_idle' } and the assertion is toEqual({ accepted: false, reason: 'session_idle' }) on the actions hop. The pre-existing no-session path returning a locally built { accepted: false } is untouched and still pinned by does not mark a stable-id admission started without a session. Acceptance mutation run: narrowing the forward at actions.ts to drop reason turns exactly this test red.

Conflict notes

None — --conflict false, no merge performed.

Verification

  • npm run build — passed (exit 0)
  • npm run typecheck — passed (exit 0)
  • npm run lint — passed (exit 0; whole-repo run completed on this runner)
  • vitest packages/web-shell focused: useQueuedPrompts.test.ts, useQueuedPrompts.dom.test.tsx, useQueuedPrompts.midTurnReconcile.test.tsx, daemon/session/actions.test.ts — 4 files, 370 passed (baseline 368 + 2 new)
  • vitest packages/web-shell full suite — 279 files, 6422 passed
  • Reproduce-first (pre-round code): new image-binding test red (length 2 vs 1, one echo); extended cleared-fallback test red at the mid-DELETE assertion ([{id:2,…}] vs []); actions-hop test green (coverage-only finding)
  • Mutation probes (each restored and re-verified green after): delete R1-1 id-binding branch → image test red; delete R1-14 drop → cleared-fallback test red; neutralize the pre-existing sync-loop removing-id guard → cleared-fallback test red (row {id:3,…} resurrects); narrow the enqueueMidTurnMessage forward to drop reason → actions-hop test red
中文说明

审查轮次处理完毕 — fc9109b85e

9add20f0ca 之上提交一个 commit,仅触及 packages/web-shell:hook 本体、其 mid-turn reconcile 套件与 session actions 套件。无需合并基线(--conflict false)。

R1-1 — Critical:空闲回退确认按渲染文本绑定,而非按 daemon 返回的 promptId(rc:3949624084,已解决)

先复现:新增用例 binds two identical image idle fallbacks by daemon prompt id(放在 drops the local duplicate when two identical idle fallbacks queue 旁边,两次 enqueue 均带图片,快照中两个 prompt 均携带该图片内容)在改动前代码上失败——一行经 appendLocalUserMessage({promptId:'prompt-2'}) 被当作已发送回显,只剩一行存活,而 daemon 仍把它排在队列里。修复严格落在指定位置:resubmittedAfterIdleRejection 确认块内、refresh.status !== 'refreshed' 分支之后,当确认 sync 什么都没绑上(bound === undefined)但快照仍把 result.promptId 列为 queued 时,按权威 id 绑定本地行(serverPromptId + serverState: 'queued'),不再落入回显并删行的路径。文本匹配器、'[image]' 占位分支与物化路径均未改动——单附件与占位文本用例仍经原路径绑定并保留其既有测试。验收变异已实跑:删除新分支后恰好只有新用例变红(1 failed / 119 skipped),套件其余全绿。

R1-14 — 已清除的 prompt 在整个 DELETE 往返期间复活(rc:3949624110,已解决)

逐字实现了建议的变体:保留靠后的 removingServerPromptIdsRef 登记,并与登记同步(中间无 await)丢掉确认 sync 刚刚为 result.promptId 物化出的行——与删除成功处理函数既有的同一过滤器——然后再发出 DELETE。useQueuedPrompts.midTurnReconcile.test.tsx 中的用例(removes a delayed idle fallback cleared before its snapshot arrives)现在通过文件里已有的 deferred<T>() helper 驱动 removePendingPrompt,并断言在确认快照 resolve 之后、DELETE resolve 之前 queuedPrompts[]。验收变异已实跑:删除该丢弃逻辑后此断言变红([{id:2,…}] vs [])。

R1-6 — 保留过滤器守卫没有测试(rc:3949624098,基于证据改变处置——线程保持打开)

在 R1-14 的丢弃落地后,该发现要求钉住的守卫已不可达:唯一能到达它的未打标记行,就是确认 sync 为刚登记的删除 id 物化出的行(两个既有 add 点会先给行打标记,由过滤器靠前的分支保留),而这些行现在与登记在同一同步块内被丢弃——任何 sync 都无法插入其中,因为其它刷新都要等自己的 getPendingPrompts await 之后才会 sync,不可能在该块内 resolve。按照"无法取证的守卫要么钉住要么删除"的规则,删掉了这两行(净 −2)。复活隐患本身已被钉住:扩展后的用例在删除进行中发布一个来自其他客户端的 pending_prompt_started,其刷新仍把该 prompt 列为 queued,并断言队列保持为空;且该断言针对真正承载此行为的守卫做了变异验证——把既有的 sync 循环 removingServerPromptIdsRef 检查中和后断言变红(会物化出新的 {id:3,…} 行)。该处置连同证据已作为回复发到线程上,而不是默默标记解决,因为发现要求的验收变异(删除过滤器守卫行)已不复存在、无法再跑。

R1-7 — 没有测试让 reason 穿过 enqueueMidTurnMessage 这一跳(rc:3949624144,已解决)

packages/web-shell/client/daemon/session/actions.test.ts 新增 forwards a daemon idle rejection across the session hop:session client resolve { accepted: false, reason: 'session_idle' },断言该跳的结果 toEqual({ accepted: false, reason: 'session_idle' })。既有的无会话路径返回本地构造的 { accepted: false } 未受影响,仍由 does not mark a stable-id admission started without a session 钉住。验收变异已实跑:在 actions.ts 收窄转发、丢弃 reason 后恰好只有该用例变红。

冲突说明

无——--conflict false,未执行合并。

验证

  • npm run build — 通过(exit 0)
  • npm run typecheck — 通过(exit 0)
  • npm run lint — 通过(exit 0;本机整仓运行完成)
  • vitest packages/web-shell 聚焦:useQueuedPrompts.test.tsuseQueuedPrompts.dom.test.tsxuseQueuedPrompts.midTurnReconcile.test.tsxdaemon/session/actions.test.ts — 4 个文件,370 通过(基线 368 + 新增 2)
  • vitest packages/web-shell 全套件 — 279 个文件,6422 通过
  • 先复现(改动前代码):新的图片绑定用例红(length 2 vs 1,一次回显);扩展的清除回退用例在 DELETE 进行中断言变红([{id:2,…}] vs []);actions 跳用例绿(纯覆盖率发现)
  • 变异探针(每次均已还原并复验为绿):删除 R1-1 按 id 绑定分支 → 图片用例红;删除 R1-14 丢弃 → 清除回退用例红;中和既有 sync 循环 removing-id 守卫 → 清除回退用例红({id:3,…} 行复活);收窄 enqueueMidTurnMessage 转发丢弃 reason → actions 跳用例红

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

🧵 Resolved all 3 selected review thread(s). · 已关闭全部选中的 3 条评审线程。

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/模型 kimi-k3 · CLI 0.23.0

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

Not reviewed: reverse audit — 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 green
  • packages/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 settles
  • packages/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 green
  • packages/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)

Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
…d state

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

Copy link
Copy Markdown
Collaborator

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

Autofix round — PR #11289

All five findings from the round-2 review are addressed in packages/web-shell/client/hooks/useQueuedPrompts.ts (commit 027799ba5b), each pinned by a new regression test in useQueuedPrompts.midTurnReconcile.test.tsx and each verified with a mutation probe (remove the guard → its test goes red → restore → green).

Feedback points

R2-1 (Critical, rc:3954525496) — text fallback can bind a co-client's prompt — FIXED

syncServerQueuedPrompts now lets matchesUnboundSubmittingRow claim a server prompt only when serverPrompt.originatorClientId === undefined || serverPrompt.originatorClientId === clientId, with clientId passed in as a parameter (the callback's dependency array is still []; refreshPendingPrompts gained clientId in its dependency array). undefined is accepted as "possibly mine", matching the sibling started-event gate, so the existing placeholder-binding test stays green. New test: never binds an image-only idle fallback to another client's prompt — a failed confirmation followed by a refresh listing a foreign '[image]' prompt (originatorClientId: 'client-other') ahead of its own; the row stays unbound until it binds its own result.promptId, and removePendingPrompt is never called. Probe: reverting the gate turns this test red (the row bound prompt-other).

R2-2 (Critical, rc:3954525509) — sync-spliced row misread as a user clear — FIXED

The branch now discriminates on the row's own provenance instead of its mere absence. syncServerQueuedPrompts records a row's id in the new syncClaimedSubmittingRowIdsRef when it splices an unbound submitting row because an already-displayed prompt matched its text; the !localRowExists branch consumes that record and settles onComplete instead of issuing a DELETE. This preserves the genuine-clear path (a clear during the await leaves no record, so the DELETE still fires — the existing removes a delayed idle fallback cleared before its snapshot arrives stays green). New test: does not delete the queued twin the sync claimed for a displayed prompt — a pending_prompt_started for an identical-text prompt splices the resubmitted row, then the confirmation snapshot lists the twin queued; no DELETE fires and the twin's row survives. Probe: dropping the record turns it red (removePendingPrompt fired for prompt-2).

R2-3 (Critical, rc:3954525517) — superseded confirmation forfeits the DELETE — FIXED

The submit body now re-awaits until it holds a non-superseded result: while (refresh.status === 'superseded' && isCurrentOwnerTokenRef.current(ownerToken) && latestSessionIdRef.current === targetSessionId) refresh = await refreshPendingPrompts(targetSessionId);. Decisions therefore read the newest payload, never a discarded one; the UI-side writes stay behind the existing sequence fence inside refreshPendingPrompts, and refreshedInBody is computed from the final result only (not widened to cover 'superseded'). New test: removes a cleared idle fallback even when an overlapping refresh supersedes its snapshot — deferred confirmation, queue cleared, unrelated pending_prompt_started bumps the sequence, deferred snapshot resolves with the prompt still queued; removePendingPrompt fires exactly once for it. Probe: removing the loop turns it red (no DELETE).

R1-14 (Suggestion, rc:3954525523) — removal re-entry races a user delete/edit — FIXED

Both halves of the guard are in place. (1) The branch returns early (settling onComplete) when removingServerPromptIdsRef.current.has(result.promptId) — the same has() pre-check removeServerPromptForAction performs — so a second DELETE never fires for a prompt another action already owns. (2) The pre-DELETE row drop now excludes isEditing || isRemoving rows, mirroring every sibling removal in this hook. Two tests pin the two halves: does not delete a cleared fallback whose removal an action already owns (mid-isRemoving row, deferred delete; removePendingPrompt called exactly once, no reportError) and keeps a row whose owning action is settling when the confirmation drops rows (the narrow window where the action's DELETE already resolved and lifted the removal-set entry while its refresh is still in flight; the branch's row drop must not steal the isRemoving row). Probes: removing either the pre-check or the filter terms turns its test red.

R2-4 (Suggestion, rc:3954525532) — id-binding ignores settled/removing markers — FIXED

The id-binding branch now computes settledOrRemoving from settledServerPromptIdsRef and removingServerPromptIdsRef — the same markers the sync treats as authoritative — and, when bound === undefined && settledOrRemoving, drops the local row, settles onComplete, and returns early so the fall-through cannot re-echo a message that never ran. queuedInSnapshot is gated on !settledOrRemoving at the binding site, and the sibling DELETE-licensing queuedInSnapshot (cleared-row branch) now also excludes settled ids, so a stale snapshot can no longer license a redundant DELETE for an already-settled prompt. New test: does not bind an idle fallback that settled before its snapshot arrivedpending_prompt_completed with state: 'removed' lands while the confirmation GET is deferred, then the stale snapshot resolves with the prompt still queued; no phantom row survives, no echo, no DELETE, no reportError. Probe: removing the guard turns it red (one row with serverPromptId: 'prompt-1').

Notes

  • The six D2-* deferred items in the review body were explicitly recorded as "not requested in this round" and were left untouched.
  • Diff growth: source +94 net, tests +564 (six regression tests written in this file's established standalone-scenario style; the reviewer requested five of them by name, and the sixth pins the second half of the R1-14 guard so every added branch has a failing-mutation witness).
  • No conflicts with main were present; no merge was performed (--conflict false).

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx vitest run client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx (packages/web-shell) — 126 passed (6 new)
  • npx vitest run client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx client/hooks/useQueuedPrompts.test.ts client/hooks/useQueuedPrompts.dom.test.tsx client/daemon/session/actions.test.ts (packages/web-shell) — 376 passed
  • Mutation probes (each: remove the guard, run the witness test, confirm failure, restore, confirm green):
    • originator gate removed → never binds an image-only idle fallback to another client's prompt FAILED as required → restored, suite green
    • sync-claim recording removed → does not delete the queued twin the sync claimed for a displayed prompt FAILED as required → restored
    • superseded re-await loop removed → removes a cleared idle fallback even when an overlapping refresh supersedes its snapshot FAILED as required → restored
    • removal-owner has() pre-check removed → does not delete a cleared fallback whose removal an action already owns FAILED as required → restored
    • settled/removing binding guard removed → does not bind an idle fallback that settled before its snapshot arrived FAILED as required → restored
    • isEditing || isRemoving filter terms removed → keeps a row whose owning action is settling when the confirmation drops rows FAILED as required → restored
  • npx prettier --check on both touched files — passed
中文说明

Autofix 本轮处理 — PR #11289

第 2 轮评审的全部 5 条发现已在 packages/web-shell/client/hooks/useQueuedPrompts.ts 中处理(提交 027799ba5b),每条都在 useQueuedPrompts.midTurnReconcile.test.tsx 中由新增回归用例固定,并逐条做了变异探针验证(移除守卫 → 对应用例变红 → 还原 → 转绿)。

反馈条目

R2-1(Critical,rc:3954525496)—— 文本回退可能绑到同会话其他客户端的 prompt —— 已修复

syncServerQueuedPrompts 现在仅当 serverPrompt.originatorClientId === undefined || serverPrompt.originatorClientId === clientId 时才允许 matchesUnboundSubmittingRow 认领某个 server prompt,clientId 以参数形式传入(该回调的依赖数组仍为 []refreshPendingPrompts 的依赖数组新增了 clientId)。undefined 按"可能是我的"接受,与同文件 started 事件路径的既有判断一致,因此原有的占位文本绑定用例保持绿色。新增用例:never binds an image-only idle fallback to another client's prompt —— 确认快照失败后的一次刷新把一个外来 '[image]' prompt(originatorClientId: 'client-other')排在自己的 prompt 之前;本地行保持未绑定,直到绑上自己的 result.promptId,且 removePendingPrompt 从未被调用。探针:还原该判断后此用例变红(行绑到了 prompt-other)。

R2-2(Critical,rc:3954525509)—— 被 sync 摘除的行被误读为用户清空 —— 已修复

该分支现在依据行自身的来源做区分,而不再仅凭"行不存在"。syncServerQueuedPrompts 在因"已被展示的 prompt 文本相同"而摘除未绑定的 submitting 行时,会把该行 id 记入新增的 syncClaimedSubmittingRowIdsRef!localRowExists 分支消费该记录并 settle onComplete,而不是发出 DELETE。真实清空路径不受影响(await 期间发生的清空不会留下记录,DELETE 照常发出 —— 既有用例 removes a delayed idle fallback cleared before its snapshot arrives 保持绿色)。新增用例:does not delete the queued twin the sync claimed for a displayed prompt —— 一条文本相同的 pending_prompt_started 摘除了重投行,随后确认快照把孪生 prompt 列为 queued;不发出 DELETE,孪生行存活。探针:去掉记录后此用例变红(对 prompt-2 发出了 removePendingPrompt)。

R2-3(Critical,rc:3954525517)—— 被取代的确认快照使 DELETE 落空 —— 已修复

提交体现在会重新等待,直到拿到非 superseded 的结果:while (refresh.status === 'superseded' && isCurrentOwnerTokenRef.current(ownerToken) && latestSessionIdRef.current === targetSessionId) refresh = await refreshPendingPrompts(targetSessionId);。因此判定依据的是最新载荷,而不是被丢弃的那一份;UI 侧写入仍留在 refreshPendingPrompts 内部既有的序号栅栏之后,refreshedInBody 仅由最终结果决定(未放宽到覆盖 'superseded')。新增用例:removes a cleared idle fallback even when an overlapping refresh supersedes its snapshot —— 延迟确认快照、清空队列、一个无关的 pending_prompt_started 推高序号、被延迟的快照以"prompt 仍 queued"resolve;removePendingPrompt 恰好以该 promptId 被调用一次。探针:去掉该循环后此用例变红(不发出 DELETE)。

R1-14(Suggestion,rc:3954525523)—— 删除重入与用户的删除/编辑竞争 —— 已修复

守卫的两半都已落地。(1) 当 removingServerPromptIdsRef.current.has(result.promptId) 时分支提前返回(并 settle onComplete)—— 与 removeServerPromptForActionhas() 前置检查一致 —— 因此对已有操作负责的 prompt 不会再发出第二次 DELETE。(2) DELETE 前的本地丢行现在排除 isEditing || isRemoving 的行,与本 hook 其他每一处删除保持一致。两个用例分别固定两半:does not delete a cleared fallback whose removal an action already owns(处于 isRemoving 的行、延迟的删除;removePendingPrompt 恰好被调用一次且无 reportError)与 keeps a row whose owning action is settling when the confirmation drops rows(删除已 resolve、删除集合登记已解除、而操作自己的刷新仍在飞行中的狭窄窗口;分支的丢行不得抢走这条 isRemoving 行)。探针:去掉前置检查或过滤器条件中的任一半,对应用例变红。

R2-4(Suggestion,rc:3954525532)—— 按 id 绑定不看 settled/removing 标记 —— 已修复

按 id 绑定分支现在用 settledServerPromptIdsRefremovingServerPromptIdsRef 计算 settledOrRemoving —— 与 sync 视为权威的同一组标记 —— 并在 bound === undefined && settledOrRemoving 时丢弃本地行、settle onComplete 后提前返回,使后续 fall-through 无法再次回显一条从未运行的消息。绑定点的 queuedInSnapshot!settledOrRemoving 为条件;DELETE 许可处的兄弟 queuedInSnapshot(清行分支)现在同样排除已 settled 的 id,使过期快照无法再为一个已 settle 的 prompt 许可多余的 DELETE。新增用例:does not bind an idle fallback that settled before its snapshot arrived —— 确认 GET 被延迟期间到达 state: 'removed'pending_prompt_completed,随后过期快照仍以 queued resolve;没有幽灵行存活、没有回显、没有 DELETE、没有 reportError。探针:去掉该守卫后此用例变红(出现一行 serverPromptId: 'prompt-1')。

说明

  • 评审正文中的 6 条 D2-* 延后项被明确标注为"本轮不要求修改",未做改动。
  • 体量变化:源码净增 94 行,测试净增 564 行(6 个回归用例按本文件既有的独立场景风格书写;其中 5 个是评审点名要求的,第 6 个用于固定 R1-14 守卫的另一半,确保每个新增分支都有"变异后必红"的见证)。
  • main 无冲突,未执行合并(--conflict false)。

验证

  • npm run build —— 通过
  • npm run typecheck —— 通过
  • npm run lint —— 通过
  • npx vitest run client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx(packages/web-shell)—— 126 通过(新增 6 个)
  • npx vitest run client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx client/hooks/useQueuedPrompts.test.ts client/hooks/useQueuedPrompts.dom.test.tsx client/daemon/session/actions.test.ts(packages/web-shell)—— 376 通过
  • 变异探针(每个:移除守卫、运行见证用例、确认失败、还原、确认转绿):
    • 去掉来源判断 → never binds an image-only idle fallback to another client's prompt 按要求失败 → 已还原,套件转绿
    • 去掉 sync 认领记录 → does not delete the queued twin the sync claimed for a displayed prompt 按要求失败 → 已还原
    • 去掉 superseded 重等循环 → removes a cleared idle fallback even when an overlapping refresh supersedes its snapshot 按要求失败 → 已还原
    • 去掉删除归属 has() 前置检查 → does not delete a cleared fallback whose removal an action already owns 按要求失败 → 已还原
    • 去掉 settled/removing 绑定守卫 → does not bind an idle fallback that settled before its snapshot arrived 按要求失败 → 已还原
    • 去掉 isEditing || isRemoving 过滤条件 → keeps a row whose owning action is settling when the confirmation drops rows 按要求失败 → 已还原
  • 对两个改动文件运行 npx prettier --check —— 通过

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/模型 kimi-k3 · CLI 0.23.0

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • the untested files half 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 one
  • docs/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 window
  • packages/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 observed
  • packages/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 update
  • packages/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)

Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
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.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Address-review round — PR #11289

Growth audit: sound (recorded in growth-audit.json). Both axes pass: the round replaces the two regenerating instance-level mechanisms (the waiver-based rendered-text matcher and the unbounded re-await loop) with root-cause mechanisms, and every hunk traces to the PR's problem or an accepted finding. Net source change is replacement-oriented; the test growth is the witness set the findings explicitly required.

Findings

  • [rc:3958629749] R2-1 (Critical, rendered-text binding) — addressed. matchesUnboundSubmittingRow no longer treats a rendered string as identity. Rows without attachments bind by exact text with the fail-open originator term intact (the daemon omits it when the submitter had none). Attachment rows claim a server prompt only through a guarded placeholder route: originator strictly this client, both sides rendered text-less ('[image] or ''), and fully comparable image payloads (files never hydrate into the summary, and a degraded/unhydrated image yields no payload, so both are refused by the payload comparison itself). The sync additionally requires the match to be unique on the server side; the started event picks a row only when it is the unique match, degrading to the pre-change no-echo branch otherwise. New pins: does not echo an ambiguous image fallback under a started prompt id (a), leaves rival image rows unbound when one placeholder prompt matches both (b), does not bind an image fallback to an originator-less placeholder prompt (c), does not bind a files-only fallback to an earlier text-less prompt (d), leaves an image row unbound when two placeholder prompts both match it (server-side uniqueness). The "id-binding branch as sole binder" alternative was not taken: the pinned never binds an image-only idle fallback to another client's prompt case binds through a follow-up sync after the body has already exited, and that premise rules it out.
  • [rc:3958629762] R2-3 (Critical, unbounded re-await) — addressed. refreshPendingPrompts is single-flight per session: concurrent callers share the in-flight GET's promise (when its owner is still current) instead of superseding each other's sequence numbers, so two confirming bodies can no longer invalidate each other forever, and the unbounded re-await loop in the submit body is deleted with it. The stale-snapshot sequence fence and owner checks are unchanged, and the loop-free shape closes the unmount hole structurally (nothing retries after teardown). New pin: bounds confirmation refreshes when two idle fallbacks confirm concurrently (macrotask-deferred GETs; asserts a bounded call count and both rows bound). removes a cleared idle fallback even when an overlapping refresh supersedes its snapshot still passes: the body shares the in-flight snapshot and the DELETE fires exactly once.
  • [rc:3958629768] R2-4 (settled term unpinned) — addressed. New test does not remove a cleared fallback whose prompt settled before its snapshot arrived: the row binds and echoes first (so the started event registers only the completion callback and leaves no started/appended markers), the row leaves the queue, pending_prompt_completed {state:'removed'} consumes the callback while the confirmation GET is parked (issuing no refresh, so the parked snapshot stays authoritative), and the stale snapshot still listing the prompt queued must not license a DELETE. Red when the settled term is deleted (probe below).
  • [rc:3958629778] R3-1 (reportError silence) — addressed. expect(harness.reportError).not.toHaveBeenCalled() added to all three failed-confirmation tests; probe (injecting reportError in the uncertain branch) turns the branch-reaching ones red.
  • [rc:3958629786] R3-2 (unbounded claim record) — addressed. The sync records a claim only for resubmittedAfterIdleRejection rows (the sole readers), and the owner-change effect resets the set beside its siblings. Probe: ungating the add leaves both claim-path tests green, as the finding predicted.
  • [rc:3958629796] R3-3 (settled-action DELETE unpinned) — addressed. expect(removePendingPrompt).toHaveBeenCalledTimes(1) added after the resolveSubmit act; red at the pre-round HEAD ("got 2 times") and green post-fix because single-flight makes the body's confirmation share the owning action's in-flight refresh, which resolves post-removal and cannot list the prompt queued. The optional guard extension (treating a live isRemoving/isEditing row as an owning action) is declined: its only remaining route is a network-level DELETE rejection racing a stale queued snapshot, which the daemon already absorbs (removed: false before the abort), so the extension would add a guard for a state the daemon already fails closed on.
  • [rc:3958629806] R3-4 (contradictory fixture) — addressed. Both snapshots of prompt-1/prompt-2 in does not delete the queued twin the sync claimed for a displayed prompt now carry originatorClientId: CLIENT_ID, matching the started event the test publishes (all three surfaces read the value captured at admission, so the previous fixture was unfaithful). New pin binds an originator-less snapshot prompt to a matching unbound row keeps the fail-open term honest in the binding direction.
  • [rc:3958629813] R3-5 (foreign prompt suppressed) — addressed. couldBeOurs is computed once and gates the attachment-suppression continue, so a prompt the originator stamp proves foreign materializes instead of being hidden behind an unbound attachment row. New pin: materializes a foreign placeholder prompt beside an unbound image fallback; never binds an image-only idle fallback to another client's prompt updated to expect the foreign prompt materialized (it previously encoded suppression as the expected outcome).

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)

  • Started-event unique pick reverted to find()does not echo an ambiguous image fallback… red.
  • Row-side uniqueness removed (length > 0) → leaves rival image rows unbound… red.
  • Relaxed-route originator made fail-open → does not bind an image fallback to an originator-less placeholder prompt red; binds an image-only idle fallback through the daemon placeholder text stays green.
  • Exact-text shortcut re-admitted for attachment rows → does not bind a files-only fallback… red.
  • Server-side uniqueness scan removed → leaves an image row unbound when two placeholder prompts both match it red.
  • Single-flight share bypassed → bounds confirmation refreshes… red; the same fixture is also red at the pre-round HEAD (unbounded loop), verified by stashing the source change.
  • Settled term deleted from the removal licence → does not remove a cleared fallback whose prompt settled before its snapshot arrived red.
  • couldBeOurs removed from the suppression → materializes a foreign placeholder prompt… red.
  • reportError injected into the uncertain branch → the failed-confirmation tests carrying the new assertion go red.
  • Exact-route fail-open term removed → binds an originator-less snapshot prompt… red; does not delete the queued twin… stays green (fixture stamping verified faithful).

Verification

  • npm run build — passed.
  • npm run typecheck — passed.
  • npm run lint — passed (repo-wide ESLint, 0 problems).
  • npx prettier --check on both touched files — passed after --write reformatting of the test file.
  • npx vitest run client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx (packages/web-shell) — 135 passed.
  • npx vitest run client/hooks/useQueuedPrompts.dom.test.tsx client/hooks/useQueuedPrompts.test.ts (packages/web-shell) — 78 passed.
  • npx vitest run (packages/web-shell, full package) — 286 files, 6684 passed (baseline 6675 + 9 new).
  • Pre-fix reproduction: with only the test changes applied to the unmodified source, exactly the 9 prescribed reds failed (four R2-1 entrances, server-side uniqueness, R3-5 materialization, the R3-5-updated never binds…, R2-3 bounds, R3-3 count); the R2-4 test and R3-4 fail-open pin are green at HEAD and red only under their named mutations, as prescribed.
中文说明

评审处理轮次 —— PR #11289

增长审计结论:sound(已记录在 growth-audit.json)。两个维度均通过:本轮把两个不断再生兄弟发现的实例级机制(基于豁免的渲染文本匹配器、无上限的重新等待循环)替换为根因机制,且每个 hunk 都能追溯到 PR 原始问题或已接受的评审发现。源码侧以替换为主;测试增长来自各发现明确要求补齐的见证用例。

发现处理

  • [rc:3958629749] R2-1(Critical,渲染文本绑定)—— 已处理。 matchesUnboundSubmittingRow 不再把渲染字符串当作身份。无附件行仍按精确文本绑定,并保留 originator 失效开放项(提交方没有 client id 时 daemon 会省略该字段)。带附件的行只能通过有守卫的占位路径认领服务端 prompt:originator 严格等于本客户端、两侧都渲染为无文本('[image]''')、且图片载荷完全可比较(文件永远不会水合进摘要,degraded/unhydrated 的图片没有载荷,二者都被载荷比较本身拒绝)。sync 侧还要求该匹配在服务端也唯一;started 事件只在匹配唯一时才选行,否则退回改动前的不回显分支。新增固定用例:does not echo an ambiguous image fallback under a started prompt id(a)、leaves rival image rows unbound when one placeholder prompt matches both(b)、does not bind an image fallback to an originator-less placeholder prompt(c)、does not bind a files-only fallback to an earlier text-less prompt(d)、leaves an image row unbound when two placeholder prompts both match it(服务端唯一性)。未采用"按 id 绑定分支成为唯一绑定者"的替代方案:已被固定的 never binds an image-only idle fallback to another client's prompt 用例依赖提交体退出后的后续 sync 完成绑定,该前提排除了替代方案。
  • [rc:3958629762] R2-3(Critical,无上限重新等待)—— 已处理。 refreshPendingPrompts 改为按会话单飞:并发调用方共享在飞行中的 GET 的 promise(当其 owner 仍有效时),不再互相作废对方的序号,两个并发确认体因此不可能永远互相失效;提交体里的无上限重新等待循环一并删除。陈旧快照序号栅栏与 owner 检查保持不变;无循环的形态从结构上关闭了卸载漏洞(拆除后没有任何重试)。新增固定用例:bounds confirmation refreshes when two idle fallbacks confirm concurrently(宏任务延迟的 GET;断言调用次数有界且两行均绑定)。removes a cleared idle fallback even when an overlapping refresh supersedes its snapshot 仍通过:提交体共享在飞行中的快照,DELETE 恰好发出一次。
  • [rc:3958629768] R2-4(settled 判断项无固定)—— 已处理。 新增用例 does not remove a cleared fallback whose prompt settled before its snapshot arrived:行先绑定并回显(于是 started 事件只注册完成回调、不留 started/appended 印记),行离开队列,pending_prompt_completed {state:'removed'} 在确认 GET 挂起期间消费回调(不发起刷新,挂起的快照保持权威),此时仍把该 prompt 列为 queued 的陈旧快照不得许可 DELETE。删除该 settled 项时变红(见下方探针)。
  • [rc:3958629778] R3-1(reportError 静默)—— 已处理。 三个确认失败用例都补上 expect(harness.reportError).not.toHaveBeenCalled();探针(在不确定分支注入 reportError)使真正到达该分支的用例变红。
  • [rc:3958629786] R3-2(认领记录无界)—— 已处理。 sync 只为 resubmittedAfterIdleRejection 行(唯一读取方)记录认领,并在 owner 变更副作用里与兄弟集合并列重置。探针:放开 add 条件后两个认领路径用例仍全绿,与发现预判一致。
  • [rc:3958629796] R3-3(已结算操作窗口的 DELETE 未固定)—— 已处理。resolveSubmit act 之后加入 expect(removePendingPrompt).toHaveBeenCalledTimes(1);在轮前 HEAD 上为红("got 2 times"),修复后变绿——单飞使提交体的确认共享了负责删除的操作其在飞行中的刷新,后者在删除后 resolve、不可能把该 prompt 列为 queued。可选的守卫扩展(把带着 isRemoving/isEditing 的存活行也视为负责方)予以拒绝:仅剩的路径是"网络层 DELETE 失败 + 陈旧 queued 快照"的竞态,而 daemon 已经吸收它(在 abort 之前返回 removed: false),扩展等于为一个 daemon 已经失败关闭的状态再加一道守卫。
  • [rc:3958629806] R3-4(fixture 自相矛盾)—— 已处理。 does not delete the queued twin the sync claimed for a displayed promptprompt-1/prompt-2 的两份快照现在都带 originatorClientId: CLIENT_ID,与该用例发布的 started 事件一致(三个面读取的都是 admission 时捕获的同一个值,原 fixture 不忠实)。新增固定用例 binds an originator-less snapshot prompt to a matching unbound row,使失效开放项在绑定方向上不被静默删除。
  • [rc:3958629813] R3-5(外来 prompt 被抑制)—— 已处理。 couldBeOurs 只计算一次,并同时用于附件抑制的 continue:originator 印记已证明为外来的 prompt 会被物化,而不再被一条未绑定的附件行挡住。新增固定用例:materializes a foreign placeholder prompt beside an unbound image fallbacknever binds an image-only idle fallback to another client's prompt 已更新为预期外来 prompt 被物化(此前它把抑制编码为预期结果)。

本轮无需维护者决定的事项,也没有延后项;按指示未触碰"延后的非 Critical"段落与评审正文记录的探针。未请求解决基线冲突。

变异探针(均已施加、实测、还原)

  • started 事件的唯一取舍退回 find()does not echo an ambiguous image fallback… 变红。
  • 行侧唯一性删除(length > 0)→ leaves rival image rows unbound… 变红。
  • 放宽路径的 originator 改回失效开放 → does not bind an image fallback to an originator-less placeholder prompt 变红;binds an image-only idle fallback through the daemon placeholder text 保持绿色。
  • 为附件行重新放行精确文本捷径 → does not bind a files-only fallback… 变红。
  • 删除服务端唯一性扫描 → leaves an image row unbound when two placeholder prompts both match it 变红。
  • 绕过单飞共享 → bounds confirmation refreshes… 变红;同一 fixture 在轮前 HEAD(无上限循环)同样为红,已通过暂存源码改动实测。
  • 从删除许可中删除 settled 项 → does not remove a cleared fallback whose prompt settled before its snapshot arrived 变红。
  • 从抑制中删除 couldBeOursmaterializes a foreign placeholder prompt… 变红。
  • 在不确定分支注入 reportError → 携带新断言的确认失败用例变红。
  • 删除精确文本路由的失效开放项 → binds an originator-less snapshot prompt… 变红;does not delete the queued twin… 保持绿色(fixture 印记的忠实性已验证)。

验证

  • npm run build —— 通过。
  • npm run typecheck —— 通过。
  • npm run lint —— 通过(全仓 ESLint,0 问题)。
  • 对两个改动文件执行 npx prettier --check —— 先经 --write 重排测试文件后通过。
  • npx vitest run client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx(packages/web-shell)—— 135 通过。
  • npx vitest run client/hooks/useQueuedPrompts.dom.test.tsx client/hooks/useQueuedPrompts.test.ts(packages/web-shell)—— 78 通过。
  • npx vitest run(packages/web-shell 全包)—— 286 个文件、6684 通过(基线 6675 + 新增 9)。
  • 修复前复现:仅把测试改动应用到未修改的源码上,恰好出现规定的 9 处红(R2-1 四条入口、服务端唯一性、R3-5 物化、经 R3-5 更新的 never binds…、R2-3 有界性、R3-3 次数);R2-4 用例与 R3-4 失效开放固定在 HEAD 为绿,仅在各自指定的变异下变红,与规定一致。

Deferred non-Critical feedback

Critical-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). (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:本计数窗口内 diff 净增长已达 源码 112 / 测试 702 行(预算 400/400)。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。纯增长触发期间维护者反馈照常流动(按作者的批次预算仅在完成 5 个产生改动的轮次后生效)。(评论 @qwen-code /retry 可开启新的计数窗口。)

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/模型 kimi-k3 · CLI 0.23.0

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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.id filter term (useQueuedPrompts.ts:2202) — already reported (round-2 review 5137546373, deferral D2-5)
  • startedOrCompleted echo 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.onComplete omission (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)

Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread docs/developers/qwen-serve-protocol.md Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
…binds

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

Copy link
Copy Markdown
Collaborator

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

Autofix round summary — PR #11289

Addressed all six Critical findings from the round-4 review (plus verified the earlier-round findings still hold). One commit: e0aadb0db1 fix(web-shell): refuse stale snapshot joins and unattributable queue binds.

Round-4 findings (all Critical, all fixed)

  1. R2-3 (fix-induced): shared refresh flight joins regardless of freshness (rc:3962365036)
    inflightRefreshRef now records the dispatch sequence, and refreshPendingPrompts(targetSessionId, notBefore) refuses to join a flight dispatched before the caller's own state change: it awaits the stale flight (no request storm) and then issues exactly one fresh GET, or joins a fresher one dispatched meanwhile. The three decision-making consumers now pass a freshness token: the resubmission confirmation in the submit body (post-admission), the pending-prompts fetch in reconcileMidTurnMessages (post-mid-turn-snapshot), and the success-path refresh in removeServerPromptForAction (post-DELETE). The bind-by-id arm additionally consults the started/displayed/completed markers (startedSinceSnapshot) so a prompt that started while the GET was in flight is never stamped queued — its Remove can no longer abort a running turn. Witnesses: does not confirm a resubmission with a snapshot older than its admission and does not bind an idle fallback that started during its confirmation snapshot.

  2. R3-2 (fix-induced): explicit-insert resubmission not covered by the claim record (rc:3962365054)
    Fixed via R4-3's mandated remedy instead of the proposed row stamp: the claim is now recorded for every spliced unbound submitting row (the flag gate is gone), so the insert path — whose row legitimately carries no flag — is covered, and both the flagged confirmation branch and the ordinary tail read the record. The row stamp itself would be dead code after the ungate (no reader of the row's flag remains: the content-less matcher route was removed by R4-1 and the gate by R4-3), so per Simplicity First it was not added; the finding's acceptance scenario is pinned by does not delete an explicit insert the sync claimed for a displayed twin.

  3. R4-1: content-less route matched flagged attachment rows on the flag alone (rc:3962365063)
    The pending_prompt_started matching route now refuses attachment rows unconditionally (return false): the event carries no content to compare media with. Ordinary submissions echo via their own admission id and resubmissions bind in the submit body's id arm. Witness: does not echo a flagged image row under another in-flight prompt (flagged image QkJC + ordinary image QUFB; the started event for the ordinary prompt echoes only QUFB).

  4. R4-2: doc promises the code doesn't keep (rc:3962365087)
    Prose-only fix in all three mirrors (docs/developers/qwen-serve-protocol.md, packages/sdk-typescript/src/daemon/types.ts, packages/cli/src/serve/routes/session.ts): session_idle now states the code's actual condition (pendingPromptCount === 0 && goalTurnActive !== true — verified at packages/acp-bridge/src/bridge.ts:13517) and that the verdict is about drainability, not hasActivePrompt; the mismatched-messageId sentence no longer promises the kept payload "is still delivered".

  5. R4-3: unflagged spliced row read as a user cancellation (rc:3962365101)
    The sync records the claim for every spliced unbound submitting row (ungated, with a 200-entry cap matching the existing ring-cap pattern so the set stays bounded now that the gate is gone), and the unflagged idx === -1 tail consults the record before issuing removePendingPrompt — claimed rows settle their callback and refresh instead of deleting the prompt the daemon just admitted. The .catch path discriminates the same way, so a POST failing inside the claim window still restores the draft and reports the error instead of losing it silently. Witnesses: does not delete an ordinary resend the sync claimed for its displayed twin and reports a failed resend whose row the sync claimed for its displayed twin.

  6. R4-4: attachment identity compared against a possibly-shortened payload (rc:3962365116)
    The matcher refuses on contentHasDegradedMedia || contentHasUnhydratedMedia before comparing images, so a payload shortened by a media 404 or an unhydrated reference can no longer "match" a prefix of a larger submission. The stale comment claiming degraded/unhydrated images yield no payload was corrected. Witness: leaves an image row unbound when the matching snapshot prompt is partially hydrated.

Earlier-round findings

All findings from rounds 1–3 were re-verified as still fixed at HEAD (code + their pinning tests: binds two identical image idle fallbacks by daemon prompt id, the extended removes a delayed idle fallback cleared before its snapshot arrives, the reason-forwarding test in actions.test.ts, the originator-gate tests, does not delete the queued twin the sync claimed for a displayed prompt, removes a cleared idle fallback even when an overlapping refresh supersedes its snapshot, bounds confirmation refreshes when two idle fallbacks confirm concurrently, does not delete a cleared fallback whose removal an action already owns, the settled-snapshot pair, the three reportError assertions, the exact-once removal assertion, the originator fixture pair, materializes a foreign placeholder prompt beside an unbound image fallback, leaves rival image rows unbound when one placeholder prompt matches both, does not bind an image fallback to an originator-less placeholder prompt, does not bind a files-only fallback to an earlier text-less prompt). R3-2's boundedness concern stays addressed after the ungate via the new reader coverage, the 200-entry cap, and the owner/session reset; R3-2's original pinning test still passes.

No merge conflicts (--conflict false; no merge performed).

Mutation probes

Every guard/branch added this round was verified by removing it and watching its witness fail:

  • content-less route reverted to the flag check → does not echo a flagged image row under another in-flight prompt failed (witness valid)
  • degraded/unhydrated refusal neutralized → leaves an image row unbound when the matching snapshot prompt is partially hydrated failed
  • claim record re-gated to flagged rows → the insert-path and ordinary-resend claim tests failed
  • unflagged claim reader disabled → does not delete an ordinary resend the sync claimed for its displayed twin failed
  • .catch discrimination removed → reports a failed resend whose row the sync claimed for its displayed twin failed
  • freshness join condition neutralized (join any flight) → does not confirm a resubmission with a snapshot older than its admission failed
  • startedSinceSnapshot term removed → does not bind an idle fallback that started during its confirmation snapshot failed (the first draft of this test did not discriminate; the probe caught it and the test was rewritten to exercise the bind-by-id arm through ambiguous identical rows)

All probes were reverted and the suites re-run to green.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx eslint on all touched files — passed
  • npx prettier --check on touched files — passed (test file reformatted with --write first)
  • cd packages/web-shell && npx vitest run client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx client/hooks/useQueuedPrompts.test.ts client/hooks/useQueuedPrompts.dom.test.tsx — 220 passed (213 baseline + 7 new), 0 failed
  • Mutation probes — 7/7 guards witness-pinned as listed above
  • Integration tests not run: the touched behavior is exercised directly by the focused hook suites; the CLI/SDK/docs changes are comment/prose-only
中文说明

Autofix 本轮总结 — PR #11289

处理了第 4 轮评审的全部 6 个 Critical 发现(并复核了前几轮发现仍然成立)。共一个提交:e0aadb0db1 fix(web-shell): refuse stale snapshot joins and unattributable queue binds

第 4 轮发现(均为 Critical,均已修复)

  1. R2-3(修复引入):共享 refresh 航班不校验新鲜度(rc:3962365036)
    inflightRefreshRef 现在记录派发序号,refreshPendingPrompts(targetSessionId, notBefore) 会拒绝加入早于调用方自身状态变更的航班:先等待过期航班结束(避免请求风暴),再恰好发起一次新的 GET,或加入期间已派发的更新航班。三个决策型调用方现在都传入新鲜度令牌:submit body 中的重提确认(POST 准入之后)、reconcileMidTurnMessages 中的 pending 拉取(mid-turn 快照之后)、removeServerPromptForAction 成功路径的刷新(DELETE 之后)。bind-by-id 分支额外查询 started/displayed/completed 标记(startedSinceSnapshot),GET 在途期间已启动的 prompt 不会再被打上 queued 标记——它的 Remove 不会再中止正在运行的回合。见证测试:does not confirm a resubmission with a snapshot older than its admissiondoes not bind an idle fallback that started during its confirmation snapshot

  2. R3-2(修复引入):显式插入的重提未被 claim 记录覆盖(rc:3962365054)
    采用 R4-3 指定的修复方式,而不是建议的行内打标:现在对每一个被拼接移除的未绑定 submitting 行都记录 claim(标志位闸门已移除),因此插入路径(其行本就没有标志)被覆盖,且重提确认分支与普通尾部路径都会读取该记录。解除闸门后行内打标将成为死代码(行的标志已无任何读取者:content-less 匹配路由已被 R4-1 移除,闸门已被 R4-3 移除),因此按"简单优先"原则未添加;该发现的验收场景由 does not delete an explicit insert the sync claimed for a displayed twin 锁定。

  3. R4-1:content-less 路由仅凭标志匹配携带附件的行(rc:3962365063)
    pending_prompt_started 匹配路由现在无条件拒绝附件行(return false):该事件不携带可比对的内容。普通提交通过自己的准入 id 回显,重提交在 submit body 的 id 分支绑定。见证测试:does not echo a flagged image row under another in-flight prompt(带标志的 QkJC 图片行 + 普通 QUFB 图片行;普通 prompt 的 started 事件只回显 QUFB)。

  4. R4-2:文档承诺了代码做不到的事(rc:3962365087)
    纯文字修复,三处镜像同步(docs/developers/qwen-serve-protocol.mdpackages/sdk-typescript/src/daemon/types.tspackages/cli/src/serve/routes/session.ts):session_idle 现在如实描述代码条件(pendingPromptCount === 0 && goalTurnActive !== true——已在 packages/acp-bridge/src/bridge.ts:13517 核实),并说明该判定只关乎消息能否被消费,而非 hasActivePrompt;messageId 不匹配句不再承诺保留的载荷"仍会被投递"。

  5. R4-3:未带标志的被拼接行被误读为用户取消(rc:3962365101)
    sync 对每一个被拼接的未绑定 submitting 行都记录 claim(解除闸门,并加了 200 条上限,与现有环形上限模式一致,确保解除闸门后集合仍有界),普通 idx === -1 尾部路径在发起 removePendingPrompt 前会先查询该记录——被认领的行改为结算回调并刷新,而不是删除 daemon 刚刚准入的 prompt。.catch 路径同样做了区分,因此 claim 窗口内失败的 POST 仍会恢复草稿并报告错误,不再静默丢失。见证测试:does not delete an ordinary resend the sync claimed for its displayed twinreports a failed resend whose row the sync claimed for its displayed twin

  6. R4-4:附件身份比对可能基于被截短的载荷(rc:3962365116)
    匹配器在比对图片前先以 contentHasDegradedMedia || contentHasUnhydratedMedia 拒绝,因此媒体 404 或未水合引用导致的截短载荷不会再"匹配"更大提交的前缀。同时修正了声称降级/未水合图片不产出载荷的错误注释。见证测试:leaves an image row unbound when the matching snapshot prompt is partially hydrated

前几轮发现

第 1–3 轮的全部发现均已复核在 HEAD 上仍然修复(代码 + 各自锁定测试,包括:binds two identical image idle fallbacks by daemon prompt id、扩展后的 removes a delayed idle fallback cleared before its snapshot arrivesactions.test.tsreason 透传测试、originator 闸门测试、does not delete the queued twin the sync claimed for a displayed promptremoves a cleared idle fallback even when an overlapping refresh supersedes its snapshotbounds confirmation refreshes when two idle fallbacks confirm concurrentlydoes not delete a cleared fallback whose removal an action already owns、settled 快照对、三处 reportError 断言、exactly-once 删除断言、originator fixture 对、materializes a foreign placeholder prompt beside an unbound image fallbackleaves rival image rows unbound when one placeholder prompt matches bothdoes not bind an image fallback to an originator-less placeholder promptdoes not bind a files-only fallback to an earlier text-less prompt)。解除闸门后,R3-2 的有界性顾虑通过新的读取覆盖、200 条上限和 owner/session 重置继续得到处理;R3-2 原有的锁定测试仍然通过。

无合并冲突(--conflict false,未执行合并)。

变异探针

本轮新增的每个守卫/分支都通过"移除后观察见证测试失败"验证:

  • content-less 路由回退为标志判定 → does not echo a flagged image row under another in-flight prompt 失败(见证有效)
  • 降级/未水合拒判被置空 → leaves an image row unbound when the matching snapshot prompt is partially hydrated 失败
  • claim 记录重新加闸门 → 插入路径与普通重发的 claim 测试失败
  • 普通尾部 claim 读取被禁用 → does not delete an ordinary resend the sync claimed for its displayed twin 失败
  • .catch 区分逻辑移除 → reports a failed resend whose row the sync claimed for its displayed twin 失败
  • 新鲜度加入条件被置空(任意航班都加入)→ does not confirm a resubmission with a snapshot older than its admission 失败
  • startedSinceSnapshot 项移除 → does not bind an idle fallback that started during its confirmation snapshot 失败(该测试第一版不具区分度,探针发现后已重写为通过相同内容的模糊行真正触发 bind-by-id 分支)

所有探针均已还原,套件重新跑绿。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • 对所有改动文件运行 npx eslint — 通过
  • 对改动文件运行 npx prettier --check — 通过(测试文件先经 --write 重排)
  • cd packages/web-shell && npx vitest run client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx client/hooks/useQueuedPrompts.test.ts client/hooks/useQueuedPrompts.dom.test.tsx — 220 通过(213 基线 + 7 新增),0 失败
  • 变异探针 — 7/7 守卫均有见证,见上
  • 未运行集成测试:本轮改动的行为由针对性 hook 套件直接覆盖;CLI/SDK/文档改动仅为注释/文字

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/模型 kimi-k3 · CLI 0.23.1

probe added 8 commits September 11, 2026 03:36
…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
@wenshao
wenshao dismissed stale reviews from doudouOUC and qqqys via df81411 September 11, 2026 08:00
@wenshao

wenshao commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Audit follow-ups deliberately not in this PR

This 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

  1. The two explicit user-action removal arms (removeServerPromptForAction, clearQueuedPrompts) neither replay nor delete a start that parked while their DELETE was in flight, so a message whose removal came back not-removed after it started is not echoed from those two paths. The three removal arms this PR added do replay. When wiring the two remaining arms, gate on the originator: the park is written before the started handler's originator check, so a co-client's prompt parks too and its rendered text must not be appended as ours.
  2. syncServerQueuedPrompts never writes inputAnnotations, but the started handler's first lookup term (a payload-complete bound row) outranks the echo stash. A row rebuilt from a fully hydrated snapshot therefore echoes an annotations-plus-image payload without its chips, and the echo then deletes the stash that held them. The settle-time consume already computes the opposite preference (stash first); the two echo routes should agree. The narrow fix is to prefer the stash when it holds annotations the bound row lacks, which keeps the summary-only shadow ordering intact.

Coverage

  1. Every test that walks submitPendingPrompt's new arms passes onComplete: undefined, so the seven callback registrations added there have no witness. One shared fixture per arm class would close it.
  2. The deferred-clear loop decides from a snapshot with no dispatch-frontier anchor, so a flight dispatched before the admission — reachable through the disconnect early-return, which bypasses the wait-out — can consume a clear recorded after it. Needs the SetMap<id, seq> change.
  3. refreshedInBody's files term has no witness (the existing it.each parameterizes images only).
  4. replayStartedDuringRemoval's displayed-guard has no witness and no reachable state was found for it — decide dead-or-pin.
  5. The submit-body immediate-removal arm's rejected-DELETE route is exercised but not pinned: dropping the () => false term of its collapse leaves its witness green, because that test's echo comes from the stash at start time rather than from the failure arm. The identical expression is pinned for both routes at the discard arm.
  6. The deferred-clear arm's fulfilled-{ removed: false } collapse has no witness of its own since its fixture was re-pointed at a lost DELETE (the reachable trigger).
  7. The protocol paragraph's closing-session half of the retry-ack ordering — a closing session answers a reasonless { accepted: false } rather than a 410 for a dead reference — has no pin. The retry-ring half now has one.
  8. The documented hasActivePrompt: true alongside session_idle (a parked deferred restore counts as in-flight activity but owns no pending prompt) has no test.
  9. Two older tests assert a row's presence by serverPromptId rather than by local row identity, so a drop-and-rematerialize would satisfy them; one asserts its headline outcome before the branch it names runs.

Robustness

  1. The three removal continuations this PR added inside refreshPendingPrompts and the submit body do not re-check the owner token after their awaits, unlike the two pre-existing user-action paths. Traced harm is bounded and self-clearing (a completion callback resurrected into a map the next owner change rebuilds); the sharper variant needs the owner snapshot to go stale mid-DELETE without a dependency change, which nobody has shown reachable. Three unwitnessed guards for that did not look like a trade worth making here.

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 doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

  1. 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-2925 keeps a local row without resubmittedAfterIdleRejection.
  2. Lift the hold while the client activity mirror reads idle. The release effect (:3178-3225) stamps only serverState:'submitting'; releaseChainedPrompt passes 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.
  3. Ordinary admission returns HTTP 202 before execution (packages/cli/src/serve/routes/session.ts:7037-7074; packages/sdk-typescript/src/daemon/DaemonSessionClient.ts:667-686 explicitly implements admission-only). With no start event and the mirror still idle, the row skips confirmation at useQueuedPrompts.ts:2027, then :2382-2397 echoes it as sent and removes it locally, although it is still queued.
  4. 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 qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

⚠️ Round 8, and the diff has grown 3.7x since this review first measured it (472 → 1758 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

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 fiv
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:2459 — [review] The new cancel-after-submit chain is a near-copy of the re
  • packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:1896 — [review] The 12-line 'another client's prompt started' event block
  • packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:4636 — [review] Five of the six client-side-evidence clauses in the deferr
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:1035 — [review] The settle keeps the stashed attachment payload alive when
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:3797 — [review] The new reason:'session_idle' taxonomy is folded into subm
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:4009 — [review] The new clear-all handoff only covers daemon ids recorded
  • packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:1553 — [review] The new it.each media arm is the one fallback arm that end
  • packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:2123 — [review] The new attachment matcher's 'a captioned row cannot own a
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:948 — [review] The displayed guard in the PR's new replayStartedDuringRem
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:829 — [review] The new server-side uniqueness count is taken over the RAW
  • docs/developers/qwen-serve-protocol.md:2786 — [review] Of the three precedence rules this paragraph adds about th
  • docs/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.

中文说明

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

⚠️ 第 8 轮,且自本审查首次测量以来 diff 已增长 3.7 倍(源码 diff 行数 472 → 1758)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

本轮确认的 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)

Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
- 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.
@wenshao

wenshao commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up list update (after f9b4de664d)

Two items from the list above are now closed rather than deferred, both because a reviewer produced an executed reproduction that my own audit had only traced:

  • Item 4 (the deferred-clear dispatch-frontier anchor) — closed. clearedUnconfirmedPromptIdsRef is a Map<promptId, anchorSeq> and a pass that predates the record skips it without consuming, so a cancellation can no longer be dropped by a snapshot that was already in flight when the user cleared. This was reported as R8-1 and had been filed against myself as a follow-up needing exactly this SetMap change.
  • The held-row provenance gap — closed, and it supersedes part of the follow-up list's assumptions about the release path. A message the daemon refused at idle and the client then held is now stamped with that provenance, so when a hold lifts the released submission confirms against a snapshot instead of trusting an activity mirror another client's queued prompt can lag. Reported in the review at acc3272759; my earlier refutation of it was wrong on that point and is corrected in the thread.

Also closed in the same commit, from R8-2: a started event's uniqueness count could not see in-flight attachment rows, so a text row rendering alike could claim an attachment prompt's echo — losing the image and duplicating the caption. The guard degrades to no echo when an attachment row renders like the event, and only then, so an unrelated attachment submission in flight does not defer a faithful text echo.

Everything else on the list stands as deferred. Each of the three fixes has a witness test that reddens without it, verified by mutation; the hook suites are at 295 tests and the full packages/web-shell run is 305 files / 7454 tests, all green locally.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

⚠️ Round 9, and the diff has grown 3.8x since this review first measured it (472 → 1788 source diff lines); the reverse audit also stopped at its round cap without converging. The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

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 added
  • packages/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 writes
  • packages/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 helpers
  • packages/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 turn
  • packages/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 green
  • packages/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 image
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:718 — [review] D9-8 the settledServerPromptIdsRef fence defeats the deferred-clear failure arm's documented re-settle of onComplete
  • packages/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 ordering
  • packages/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 assertion
  • packages/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.

中文说明

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

⚠️ 第 9 轮,且自本审查首次测量以来 diff 已增长 3.8 倍(源码 diff 行数 472 → 1788);反向审计也在轮数上限处停止且未收敛。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

本轮确认的 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)

Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
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.
@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Maintainer verification — real daemon + real browser, Linux

I built a local verification environment and drove this PR end to end against a real qwen serve daemon in a real Chromium, which is the one gap the PR body leaves open:

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.

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 deterministic

The 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:

  1. Run a real ~10 s turn through the real daemon (local OpenAI-compatible mock model, 40 chunks × 250 ms, replies ACK<prompt>).
  2. The moment the client shows the turn as running, park its event delivery — an addInitScript wrapper around window.fetch holds the chunks of GET /session/:id/events plus the activity polls (/session/:id/status, /sessions/live-state, /session/:id/turn-index) and releases them in order. No app code is patched; only delivery timing, which is the race.
  3. Poll the daemon directly until GET /session/:id/status reports {"activeWorkState":"idle","hasActivePrompt":false}.
  4. Assert the client still disagrees — the composer placeholder still reads Processing. New messages will be queued. (its own streaming mirror). This precondition held in every run — 11 runs of case A alone.
  5. Type the follow-up and send it from inside that window. The client takes the mid-turn insert path; the daemon refuses because the session is idle.

A/B arms differ only in the two web-shell client files this PR touches:

arm bundle
pr the bundle npm ci produced at head 768f876421 — verified byte-identical to a fresh npx vite build at head, so the arm recipe reproduces the shipped artifact
base same tree with client/hooks/useQueuedPrompts.ts and client/components/QueuedPromptDisplay.tsx reverted to merge-base ae78d5b800a (proved byte-identical to git show ae78d5b800a:<file>), rebuilt the same way

Same daemon binary in both arms, so every difference below is the client half of this PR.

Case A — the reported bug

before/after in the race window

Identical wire answer in both arms, observed in the page:

POST /session/:id/mid-turn-message → 200 {"accepted":false,"reason":"session_idle"}
base this PR
failure surfaced Daemon rejected mid-turn message none
follow-up in the transcript never appears appears once
prompts carrying the follow-up that reached the model 0 1
resubmission on the wire POST /session/:id/prompt → 202
runs 4 runs, identical 4 failures each 7 runs, 9/9 checks each

final transcripts

The daemon-side one-liner also checks out directly on Linux — an idle session answers {"accepted":false,"reason":"session_idle"}, a repeated messageId answers the same, and mid-turn-messages / pending-prompts stay empty afterwards, so the refusal transfers no ownership ([mid-turn] session="…" rejected id "…": session idle in the daemon log).

Case B — image-only follow-up in the same window

The 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 GET /session/:id/pending-prompts → {"pendingPrompts":[{"text":"[image]", …}]}.

base this PR
failure surfaced Daemon rejected mid-turn message none
image-bearing prompts the model received 0 1 ([image: image/png])
image bubbles in the transcript 0 1 (not duplicated, not a bare placeholder)

Case C — daemon queues the resubmission behind another client's turn, then the row is removed

To reach this deliberately, the browser's POST /prompt is held 4 s in the page's network layer and a second client takes the prompt FIFO during the hold. Everything the daemon does is real.

queued behind another turn, then removed

10/10 checks pass on the PR arm:

  • the daemon really holds rival: state "running" + follow-up: state "queued";
  • the UI keeps the follow-up as a queue row labelled Queued on server...not rendered as sent;
  • the removal DELETEs only the queued promptId, never the running one;
  • the running turn completed in full (all 39 chunks of its answer arrived);
  • the removed follow-up never ran (0 prompts at the model).

One honest caveat on strength: I mutated queuedInSnapshot to false (the daemon-queued confirmation this PR adds), rebuilt, and the mutant survived all 10 checks. The row's visibility in this scenario comes from the pending-prompts sync, so this scenario confirms the behaviour but does not pin that particular clause. The hook-level tests in the PR are where that clause is pinned.

Case D — the backward-compatibility claim, measured

Stripping reason from the refusal in flight makes the real daemon look like a pre-reason daemon. The new client then takes the old failure path: error toast, message lost (0 prompts at the model).

pre-reason daemon

This is consistent with what the PR documents (clients keep their own idle detection alongside it) — the client-side signal is exactly the one that lags — but it is worth stating plainly: the fix is not client-only. For the Web Shell this is a non-issue (the daemon serves the bundle, so the halves ship together); it matters only for a separately-versioned @qwen-code SDK consumer talking to an older daemon.

Finding 1 (new, Minor) — the resubmitted turn is folded into the previous transcript block, until a reload

After the fallback resubmits, the daemon reports 2 turns (GET /session/:id/turn-index) but the live UI renders one block: a single Processed header that has lost its duration, with both answers concatenated. A reload of the same session renders it correctly as two blocks, Processed 11s each.

live vs reloaded

Controlled twice:

  • Not a burst-delivery artifact of my harness: an ordinary second prompt submitted across the same parked-stream burst renders two blocks (["Processed 11s","Processed 11s"], not merged). The merge is specific to the resubmission path.
  • Not an artifact of the widened window: releasing the stream as soon as the refusal is answered (production-like timing) still merges.

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) — clearQueuedPrompts has no UI trigger today

The PR's reviewer test plan asks for "clear the queue while such a follow-up is being resubmitted". In the current Web Shell a user cannot do that: onClearQueuedMessages is declared on ChatEditor/useComposerCore and passed from App.tsx/ChatPane.tsx, but it is never destructured or called — at merge-base and at head alike, and all three files are untouched by this PR. So the footer string Press ↑ to edit the latest queued message · Esc to clear queue documents a gesture that does nothing; pressing Esc in case C left both pending prompts exactly where they were. The reachable removal path is the row's own Delete control (removeServerPromptForAction), which is what case C above exercises.

Consequence for this review: the clear-all half of the "confirm before cancelling" work is currently reachable only from tests, not from the product. That is a reason to keep the hook-level coverage, not a reason to change this PR.

Changed test files, re-run on Linux

The PR body reports its suites from macOS with nine unrelated failures. On this box the changed files are all green:

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 已经空闲,但浏览器的流式状态镜像还没跟上。生产环境里这个窗口只有几毫秒。这里是刻意打开的,并且每次运行都断言它确实存在:

  1. 通过真实 daemon 跑一个真实的 ~10 秒回合(本地 OpenAI 兼容 mock 模型,40 chunk × 250ms,回复 ACK<提示词>)。
  2. 一旦客户端显示回合正在运行,就把它的事件投递挂起 —— 用 addInitScript 包裹 window.fetch,把 GET /session/:id/events 的 chunk 以及活动轮询(/session/:id/status/sessions/live-state/session/:id/turn-index)缓存下来,之后按序释放。不改任何应用代码,只改投递时序 —— 而时序本身就是这个竞态。
  3. 直接轮询 daemon,直到 GET /session/:id/status 返回 {"activeWorkState":"idle","hasActivePrompt":false}
  4. 断言客户端仍然不同意 —— 输入框占位符仍是 Processing. New messages will be queued.(客户端自己的流式镜像)。每次运行该前置条件都成立 —— 仅用例 A 就有 11 次。
  5. 在这个窗口里输入追问并发送。客户端走 mid-turn 插入路径,daemon 因会话已空闲而拒绝。

A/B 两臂相差本 PR 改动的那两个 web-shell 客户端文件:

分支臂 产物
pr head 768f876421npm ci 产出的 bundle —— 已验证与在 head 上重新 npx vite build 的结果逐字节相同,说明这套构建配方能复现实际发布产物
base 同一棵树,仅把 client/hooks/useQueuedPrompts.tsclient/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 里用户做不到:onClearQueuedMessagesChatEditor / useComposerCore 里声明了,也从 App.tsx / ChatPane.tsx 传了进去,但从未被解构、也从未被调用 —— merge-base 与 head 都是如此,而且这三个文件本 PR 都没碰。所以页脚文案 Press ↑ to edit the latest queued message · Esc to clear queue 里承诺的手势是失效的;用例 C 里按 Esc 后,两个 pending prompt 原封不动。真正可达的移除路径是队列行自带的 DeleteremoveServerPromptForAction),也就是上面用例 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 qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

⚠️ Round 10, and the diff has grown 3.8x since this review first measured it (472 → 1802 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

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 witness
  • packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:5277 — [probe] Test asserts before the branch it names can run
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:428 — [probe] The matcher's opening row-state guard is unwitnessed
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:469 — [probe] The !serverImages refusal is unwitnessed and guards a throw
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:1007 — [review] New comment asserts a sibling refusal that does not exist
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:1184 — [probe] Three removal-failure arms skip the owner-token re-check
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:2195 — [probe] refreshedInBody reset guard has no witness
  • packages/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 轮,且自本审查首次测量以来 diff 已增长 3.8 倍(源码 diff 行数 472 → 1802)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

本轮确认的 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)

Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
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
@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Round 25 of the local audit — dispositions (9762fc3105)

Twenty auditors over the whole diff, two independent rounds. Everything below was verified against the code before being acted on; the two refutations include the evidence rather than an assertion.

Fixed, reproduced first, mutation-verified

  • An explicit insert the daemon refused at idle while a hold was active went back to being held without the idle provenance that the mid-turn requeue persists. When the hold lifted, the drain released it into an ordinary submission that skipped the daemon 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 blocked every later snapshot from restoring the row. The provenance is now persisted on the row that returns to the hold, and the insert-recovery copy no longer strips a flag the row already carries. Witness: confirms a released held row whose explicit insert was refused at idle — pre-fix it produced one premature echo where silence was owed.

Refuted, with evidence

  • "A text-only row can be bound to a media-bearing prompt that renders the same caption" — real, but not this PR's: at the merge base the sync's filter was !p.serverPromptId && serverState === 'submitting' && no images && no files && p.text === serverPrompt.text, i.e. the same rendered-text binding with no content inspection, no originator gate and no server-side uniqueness check. This PR narrowed that hole; it did not open it. Filed as a follow-up with the suggested content-parity refusal rather than changing the mainline bind route in a 25-round-old PR.
  • "The boundAtSeq retention exemption anchors on the live counter while the deferred-clear guard anchors on the pass's own sequence, so a mid-pass dispatch lapses the exemption" — measured: the stretch from the dispatch-sequence fence to the syncServerQueuedPrompts call contains zero await, so the two anchors are provably equal. The exemption's comment now says why the live read is safe.

Corrected contract statements (six in the hook, three doc surfaces)

A release-chain link can be an idle-rejected resubmission now, so its promise spans the confirming snapshot — three comments said otherwise, and a follow-up item had been closed as "a phantom cost" on exactly that premise; it is re-opened below. 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. A confirmed removal means the prompt never dispatched or was aborted by it. Refreshes are not strictly single-flight per session, so the sequence fence is named as the guarantee. The idle-provenance field itself is now documented as provenance rather than state. In the protocol reference, the re-attach resend duty is scoped to the close window, requires a new messageId (a repeated id is acked idempotently and re-arms nothing), covers the still-queued case a refused close leaves behind, and names both rings as bounded — so an id that reports nowhere may have aged out after delivery rather than been dropped. The removal-retention clause is scoped to the running branch in the doc, the SDK JSDoc and the route comment alike. One negatives-only insert test gained the positive control its siblings already had.

Recorded open, not silently dropped

  1. The insert-recovery provenance fix above ships without an executed pin. Two fixture attempts did not reach the recovery path — the hold-lift drain skips isInserting rows, but an ambient snapshot binds the row by rendered text before the refusal resolves, and parking the confirming GET moved the failure elsewhere. Neither attempt reddened under mutation, so both were deleted rather than left as vacuous witnesses. Follow-up: build a fixture that keeps the snapshot empty until the recovery submits, or establish that submitAtIdle with no idle reason is unreachable while a flagged row is being inserted and revert those three lines.
  2. Re-opened: detaching the confirming snapshot from the promise a release-chain link returns. Draining N idle-refused held rows serializes one extra queue round trip per flagged link ahead of every later link and of anything typed during the drain.
  3. The four-arm idle-rejection guard covers the hold twice and two of targetIsCurrent()'s three terms, but not the owner-token term — the only one that can be false while the session and cwd comparisons still hold. Narrowing the gate to the two explicit comparisons ships green today.
  4. refreshedInBody asks whether this body's payload carries attachments while the suppression it compensates for is computed over every unbound submitting row, so a text-only resubmission confirming beside a sibling's unbound image row can skip the re-refresh. Needs a write-block rather than a Goal plus a quiet session to stay unbounded.
  5. Two it.each(['refused','failed']) arms publish the start after a resolved {removed: false} — the ordering the earlier sweep re-pointed elsewhere; their refused arms should move to the parked-DELETE shape.
  6. A close the child definitively refuses leaves the session live with the rest of the mid-turn queue still listed and nothing scheduled to promote it until some later turn ends. Pre-existing daemon behaviour this PR only documented; the re-arm belongs in a bridge change with its own witness.

Locally: hook suites 298 tests, full packages/web-shell 305 files / 7457 tests, tsc for web-shell / sdk-typescript / cli, ESLint and Prettier all green.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

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 /review. See workflow logs.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Blocking finding(s) follow.

Partially reviewed — gaps disclosed.

⚠️ Round 11, and the diff has grown 4.0x since this review first measured it (472 → 1865 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

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.

⚠️ 9 finding(s) still carried the — [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 variable
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:2069 — [review] Stash-and-evict loop duplicated five times verbatim instead of one helper
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:3744 — [review] resubmittedAfterIdleRejection provenance flag written through functions whose Pick types exclude the field
  • packages/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.

中文说明

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

⚠️ 第 11 轮,且自本审查首次测量以来 diff 已增长 4.0 倍(源码 diff 行数 472 → 1865)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

未决,请确认:共 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 条。

⚠️ 循环结束时仍有 9 条发现带着 — [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)

Comment thread docs/developers/qwen-serve-protocol.md Outdated
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
Comment thread packages/web-shell/client/hooks/useQueuedPrompts.ts
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.
@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Rounds 10-11 of the automated review, plus three local audit rounds — dispositions

Everything 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, packages/web-shell 305 files / 7471 tests green, tsc and ESLint clean, Prettier clean.

From the automated review (6 Criticals across rounds 10 and 11, all confirmed, none refuted)

Finding Outcome
R10-3 / sync claim splices an unreleased held row Fixed at the matcher, not in one arm — see the thread. The proposed exemption term went in first and was then superseded, because the bind arm below consumed the same row.
R10-4 / settle's last-chance echo gated on a park, stash deleted unread Fixed by a different mechanism. The proposed consult shipped first and was reverted: it echoed messages the daemon never ran, since a session close publishes turn_error for queued prompts too. The park now covers the case instead.
R10-5 + R11-4 / one duplicate echo, two proposed fixes Fixed at the source: a settle that clears an already-set displayed marker drops the park beside it. Both proposals were measured and rejected — the settled term blocks the deferral those arms exist for, and the marker write was not load-bearing once the park is dropped.
R11-1 / resend rule missing the DELETE step Fixed, then fixed again: the first rewrite's verdict condition was itself wrong for an id promoted between the query and the delete.
R11-2 / user-action removal arms never replay Fixed at all three named sites plus the placeholder refusal, and plus two consequences of wiring them (a replay that could not read a payload-complete row, and a park written before the originator check).
R11-3 / suppressed start has no payload source Fixed for the attributable case; the proposed stash-at-suppression was rejected as unsound. The genuinely ambiguous case is recorded below.

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 queue.queueFailed toast beside a message the echo had just made visible.

Reverted after analysis, so they are not re-proposed: the started handler's raw-text branch writing appendedBeforeResponsePromptIdsRef (not load-bearing, and it would reroute bodies away from the confirming refresh); widening the replay's row source to any payload-complete row (a hydration-loss row carries [image] as its text with no media, so it would echo a literal placeholder bubble).

Corrected as inaccurate: five code comments and six test comments or fixtures that stated something the shipped daemon contradicts — among them three fixtures justifying {removed:false} with "the daemon starts the prompt inside the DELETE flight", a media-less '[image]' snapshot entry, and a witness describing pre-fix behaviour as current. Also removed as dead: a provenance spread in recoverAfterSettledInsert that clearInsertionFlag had already written (deleting it left 297 tests green; the load-bearing term is the one in nextFlags, which reddens two tests).

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

  1. syncServerQueuedPrompts never writes inputAnnotations, but the started handler's first lookup term (a payload-complete bound row) outranks the echo stash. A row rebuilt from a fully hydrated snapshot therefore echoes an annotations-plus-image payload without its chips, and the echo then deletes the stash that held them. The settle-time consume already computes the opposite preference (stash first); the two echo routes should agree. The narrow fix is to prefer the stash when it holds annotations the bound row lacks, which keeps the summary-only shadow ordering intact.
  2. When a started event's echo is suppressed because two in-flight attachment rows render alike, the park records no sole candidate and the failing body stays silent, so that message still reaches no transcript. Round 26 fixed the attributable half — a park now records the single row that could own the event, and a body whose admission fails after the daemon started the prompt hands its payload to that park before the row goes — and pinned the ambiguous half as deliberate silence, since echoing either row's attachments would be a guess about which message the daemon ran. Attribution is recoverable in principle: once every rival body has resolved, only one row is left that could own the settled id. Using that needs either the park to be re-examined when a rival body binds, or content in the daemon's pending_prompt_started. The reproducing variants are preserved in the audit scratch directory.
  3. The text-only version of the same race is echoed by nobody: two identical text submissions in flight, so the park names no sole candidate; the one whose admission fails after the daemon started it is gated out of the failing body's consume (which requires a payload the event cannot reproduce), and the settle defers to the sibling's body, which reads only its own prompt id. Here the parked text is the whole message, so an echo could not misattribute a payload — but deciding which of two identical submissions the park belongs to still needs a rule this file does not have.
  4. A user whose entire message is the literal text [image] loses that echo when a removal fails: the replay's bound-row source requires a payload the event cannot reproduce, and the raw branch refuses the placeholder. Widening the row source was tried and reverted — it would echo a literal placeholder bubble for the hydration-loss rows that carry that text with no media, which is the worse trade.

Coverage

  1. Every test that walks submitPendingPrompt's new arms passes onComplete: undefined, so the seven callback registrations added there have no witness. One shared fixture per arm class would close it.
  2. refreshedInBody's files term has no witness (the existing it.each parameterizes images only).
  3. replayStartedDuringRemoval's displayed-guard has no witness and no reachable state was found for it — decide dead-or-pin. Its stashed-payload branch is pinned for the deferred-clear and submit-body sites and its bound-row branch for the delete button; the image-plus-stash combination at a user-action site has no witness of its own.
  4. The submit-body immediate-removal arm's rejected-DELETE route is exercised but not pinned: dropping the () => false term of its collapse leaves its witness green, because that test's echo comes from the stash at start time rather than from the failure arm. The identical expression is pinned for both routes at the discard arm.
  5. The deferred-clear arm's fulfilled-{ removed: false } collapse has no witness of its own since its fixture was re-pointed at a lost DELETE (the reachable trigger).
  6. The sync's exclusion of unreleased rows from submittingMatches is pinned only through the splice witness. Its other half — the bind arm no longer stamping an unreleased row with an id it cannot own, which every later drop-by-id would then treat as that prompt's row — has no witness of its own.
  7. The protocol paragraph's closing-session half of the retry-ack ordering — a closing session answers a reasonless { accepted: false } rather than a 410 for a dead reference — has no pin. The retry-ring half now has one. Round 26 added two more unpinned claims to the same paragraph: that a close the child refuses leaves the queue stranded until a later settle promotes it (shouldSettleMidTurnQueue in the bridge), and that a DELETE of an id promoted between the query and the delete returns { removed: true } while the child already holds the payload. The resend rule that depends on both is documented; neither behaviour is pinned.
  8. The documented hasActivePrompt: true alongside session_idle (a parked deferred restore counts as in-flight activity but owns no pending prompt) has no test.
  9. Two older tests assert a row's presence by serverPromptId rather than by local row identity, so a drop-and-rematerialize would satisfy them; one asserts its headline outcome before the branch it names runs.
  10. does not append a delayed idle fallback after the session changes asserts a true outcome but does not isolate the post-await owner/session guard it sits beside: deleting that guard leaves the test green, because the session change resets the rows, so the body takes the !localRowExists arm, which neither appends nor deletes. Pinning it needs an attachment payload plus a snapshot reporting the prompt running. The fixture's orphaned second deferred-GET factory was removed in round 26 and the resolver it binds is now asserted defined.
  11. The four-arm does not submit an idle rejection across %s changes guard covers shouldHold twice and two of targetIsCurrent()'s three terms, but not the owner-token term — the only one that can be false while the session and cwd comparisons still hold. Narrowing the gate to the two explicit comparisons ships green today.
  12. The two it.each(['refused','failed']) arms of completes a cleared first-time submission whose removal is %s and the prompt runs publish the start after a resolved { removed: false }, so they do not exercise the parked-start ordering their siblings do. Restructure both refused arms to the parked-DELETE shape.
  13. The resubmission tail is the only post-await echo that consults no dedupe marker at all — neither the displayed set, nor appendedBeforeResponsePromptIdsRef, nor the settled set. Three auditors have now traced it and none could reach a duplicate: the settled-or-removing arm returns first whenever no row is bound, and a bound row means the started handler's first lookup term matched it. Recorded rather than guarded.
  14. While any drain-stamped attachment row is unreleased, hasUnboundAttachmentSubmission suppresses materializing every other same-client prompt in that snapshot, text-only ones included, so those daemon-queued rows stay invisible until the drain releases and a later refresh runs. Whether a snapshot with no following refresh is reachable was not established.

Robustness

  1. The three removal continuations added inside refreshPendingPrompts and the submit body do not re-check the owner token after their awaits, unlike the two pre-existing user-action paths. Traced harm is bounded and self-clearing (a completion callback resurrected into a map the next owner change rebuilds); the sharper variant needs the owner snapshot to go stale mid-DELETE without a dependency change, which nobody has shown reachable.

  2. refreshedInBody asks whether this body's payload carries attachments, while the suppression it compensates for is computed over every unbound submitting row, so a text-only resubmission confirming beside a sibling's unbound image row records the suppressed snapshot as its sync and skips the .finally re-refresh.

  3. clearQueuedPrompts does not exclude a row whose id another flight is already removing, so a delete followed by a clear before the first DELETE resolves issues a second one; the per-task finally then clears the removing flag the first flight still needs, and the second DELETE's { removed: false } lands in the failure path — a spurious error toast and a transiently resurrected row. Programmatic-only today: onClearQueuedMessages is declared in two prop types and invoked by neither, so no shipped UI reaches the clear path at all. Wiring it is a separate decision.

  4. The daemon side of a definitively refused close still leaves the mid-turn queue stranded with nothing scheduled to promote it. Round 26 documented the client's duty (delete, then resend under a new id, conditioned on promotedMessageIds); re-arming the queue in the bridge is a separate change with its own witness.
    Shape, not defects

  5. The stash-and-evict loop (write, then drop oldest past a 200-entry cap) is repeated verbatim at five sites in useQueuedPrompts.ts. One helper would remove ~40 lines; it touches five call sites in a 4300-line file, which is not a trade to make mid-review.

  6. resubmittedAfterIdleRejection travels through Partial<Pick<QueuedPrompt, …>> signatures that do not name the field, so it survives only because object spread copies properties the types omit. Widening three signatures would make that explicit; until then a refactor that rebuilds the object from the typed fields would silently drop the provenance.

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 !settledServerPromptIdsRef.has(id) to the two submit-body park-consume arms, to stop a settle from leaving a park that a later body reads as an echo it owes: it does stop it, but it also blocks the case those arms exist for — a settle that defers to a body still in flight — which an existing pin covers. The duplicate was fixed at its source instead: a settle that clears an already-set displayed marker now drops the park beside it, since the settled set carries the same fact for every reader.

Writing appendedBeforeResponsePromptIdsRef in the started handler's raw-text branch, proposed for the same duplicate: applied and reverted. Once the park is dropped at the settle, both park-consume arms are closed; the fall-through echo it was also credited with covering is unreachable for a settled prompt, because the settled-or-removing arm returns first whenever no row is bound, and a bound row means the started handler's first lookup term matched it, so no raw echo and no park ever existed. It also has a second effect nobody asked for — the marker is consumed before the body's await, so a body whose POST resolves after a raw echo would skip the confirming refresh the provenance flag exists to force. Removing the write reddened nothing in 742 tests.

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 '[image]' rendering as its text with no media, and appendLocalQueuedPrompt has no placeholder refusal, so the change trades one lost echo (item 4) for a literal placeholder bubble in the transcript.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

⚠️ Round 12, and the diff has grown 4.3x since this review first measured it (472 → 2018 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

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* b
  • packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:1732 — [review] The PR pastes the same mock-wiring boilerplate into every
  • packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:2415 — [probe] one resolver delivery in the new fence tests is unpinned
  • packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:11338 — [probe] The new test echoes nothing when a parked start still ends
  • packages/web-shell/client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx:15445 — [probe] The last of the four new tests reaches the brand-new faili
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:433 — [probe] The text route's foreign-originator refusal is untested; t
  • packages/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 who
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:990 — [probe] The new sentinel refusal drops a user message whose litera
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:1063 — [review] The settle-time echo writes an appendedBeforeResponsePromp
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:1063 — [probe] The skip-write guard for a body that already returned unbo
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:1128 — [review] The new notBefore optional parameter is never supplied by
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:1196 — [probe] The deferred-clear loop's "a removal this client already o
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:1866 — [probe] The unreleased-row filter on unboundMatches (text rows) is
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:1946 — [probe] The prompt ? [] : half of this round's new soleCandidateRo
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:2275 — [probe] This round added two things beside the deferred-clear reco
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:2402 — [probe] The boundAtSeq stamp on the idle-resubmission bind path (b
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:2552 — [review] Two of this new guard's four disjuncts are provably always
  • packages/web-shell/client/hooks/useQueuedPrompts.ts:2598 — [probe] The new confirmed-removal branch of the idx === -1 arm cle
  • packages/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.

中文说明

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

⚠️ 第 12 轮,且自本审查首次测量以来 diff 已增长 4.3 倍(源码 diff 行数 472 → 2018)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

本轮确认的 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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),sendPromptsession_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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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)

Comment on lines +2673 to +2674
let echoedParkId: string | undefined;
if (admissionStarted && eventCannotReproducePayload(prompt)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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)

Comment on lines +4272 to +4276
// 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!);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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,提示词确实运行过),而确认性的刷新仍在进行中或直接失败。实测结果:一条消息同时在对话记录里,且该行以 queuedisRemoving: 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());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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)

Comment on lines +1917 to +1919
(uncountableAttachmentRow || unboundMatches.length !== 1
? undefined
: unboundMatches[0]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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 的用例,断言 appendLocalUserMessagepromptId: '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.
@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

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 appendedBeforeResponse entry, because that flag 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. All three ingredients are present at the parent commit 9762fc3105, so this predates the round; what the round contributed was a justification ("every reader that dedupes against the park also reads the settled set") that was wrong about the coverage, and auditing that claim is what exposed it. Fixed where the marker is cleared: the settle now records appendedBeforeResponse whenever it clears a marker it found set, which covers the bound-row, raw-text and removal-replay routes at once instead of one branch. Witness does not re-echo a bound row whose settle beat its own admission — red at 2 appends, green at 1, and removing the record reddens exactly it.

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 [image] caption above the picture it stands for — including the row source the previous commit added to the removal replay. Fixed at the single echo funnel rather than at one caller: the placeholder is dropped from the caption and folded into the empty-payload refusal, so a row that is only a placeholder with no attachments left echoes nothing instead of an empty bubble. Witness echoes a hydrated image row without the daemon placeholder as a caption.

One Suggestion on witness quality, acted on against myself. A caption witness written during this round to pin the replay's payloadCompleteness !== 'summary-only' term was removed as vacuous: dropping that term, or dropping the eventCannotReproducePayload term beside it, left all 745 hook tests green, because appendLocalQueuedPrompt's own refusals cover every shape the fixtures build. Both terms are now recorded as unpinned on the follow-up list rather than deleted, since they mirror the settle's identical source and the distinguishing shape — a partially hydrated row still marked summary-only while carrying some images — has no fixture. Two comments that overclaimed what their tests pin were also corrected.

Gates at d16e53f: packages/web-shell 305 files / 7473 tests green, tsc clean, ESLint clean, Prettier clean. Mutations run this round: 4 (M22-M25), each reddening exactly its own witness or nothing at all.

The clean-pass count restarted again: round 29 was not dry. Round 30 runs on d16e53f.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants