Skip to content

fix(web-shell): allow shell commands in new tasks without a session - #7724

Merged
wenshao merged 21 commits into
QwenLM:mainfrom
wenshao:fix/webshell-shell-command-new-task
Jul 27, 2026
Merged

fix(web-shell): allow shell commands in new tasks without a session#7724
wenshao merged 21 commits into
QwenLM:mainfrom
wenshao:fix/webshell-shell-command-new-task

Conversation

@wenshao

@wenshao wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Improves the ! (shell command) experience in Web Shell in two ways:

  1. New tasks: Typing a ! command in a brand-new task now lazily creates a session (via the same ensureSessionForPrompt() path used by regular messages) instead of showing "No active session yet" and rejecting the input.
  2. During a turn: Instead of blocking ! commands with an error toast while a turn is running, they are now queued client-side and executed sequentially after the turn finishes. The user gets an info toast confirming the command was queued.

Why it's needed

Previously, ! commands had two unnecessary restrictions:

  • New tasks: The user had to send a regular message first to create a session before running any shell command. This is unintuitive — the lazy session creation infrastructure already exists.
  • During a turn: Shell commands were hard-blocked with "Shell commands can't be queued while a turn is running." Regular messages are queued during turns, so users expect the same for ! commands. Since shell commands execute via a separate API (sendShellCommand, not submitPrompt), they can be safely queued client-side and drained when the turn completes — no daemon-side changes needed.

Reviewer Test Plan

How to verify

New task flow:

  1. Open Web Shell and start a new task (no existing session).
  2. Type !echo hello and press Enter.
  3. Expected: A session is created automatically and the shell command executes, showing hello in the chat.
  4. Previously: A toast appeared saying "No active session yet" and the command was rejected.

Mid-turn queuing:

  1. Send a message that triggers a long-running turn.
  2. While the turn is streaming, type !echo queued and press Enter.
  3. Expected: An info toast appears: "Shell command queued — it will run after the current turn finishes."
  4. When the turn finishes, the queued shell command executes automatically and its output appears in the chat.
  5. Queue multiple ! commands during a turn — they execute sequentially in order after the turn ends.

Evidence (Before & After)

N/A (requires a running daemon to demonstrate; covered by unit tests).

Tested on

OS Status
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

Environment (optional)

npx tsc --noEmit + npx vitest run client/App.test.tsx (173 tests passed).

Risk & Scope

  • Main risk or tradeoff: A session is created even if the user only intended a quick shell command in a new task. This is consistent with how regular messages work and uses the existing deduplication in ensureSessionForPrompt() (createSessionPromiseRef), so rapid ! commands only create one session.
  • Queued shell commands are stored in a client-side ref (queuedShellCommandsRef). They are lost on page refresh or session switch — this is acceptable since they are transient, fire-and-forget commands.
  • Not validated / out of scope: Concurrent shell command execution during a turn (commands are deliberately queued and run after the turn, not in parallel — concurrent execution would require handling promptStatus clobbering, Stop-button cancellation, and interleaved output rendering).
  • Breaking changes / migration notes: None.

Linked Issues

N/A

中文说明

本 PR 做了什么

改进 Web Shell 中 !(shell 命令)的体验:

  1. 新任务: 在全新任务中输入 ! 命令时,通过 ensureSessionForPrompt() 按需创建 session 后执行,不再弹出"尚无活动会话"并拒绝输入。
  2. 回合中: 回合运行时 ! 命令不再被拦截报错,而是排入客户端队列,回合结束后按顺序自动执行。用户会看到 info toast 确认命令已排队。

为什么需要

之前 ! 命令有两个不必要的限制:

  • 新任务: 用户必须先发一条普通消息创建 session,才能执行 shell 命令。但懒创建 session 的基础设施已经存在。
  • 回合中: Shell 命令被硬性拦截,提示"Shell 命令不能进入排队"。普通消息在回合中可以排队,用户期望 ! 命令也能如此。Shell 命令走独立的 API(sendShellCommand,非 submitPrompt),可以安全地在客户端排队、回合结束后 drain,无需 daemon 侧改动。

审阅测试计划

如何验证

新任务流程:

  1. 打开 Web Shell,新建任务(无现有 session)。
  2. 输入 !echo hello 回车。
  3. 预期: 自动创建 session 并执行命令,聊天中显示 hello
  4. 之前: 弹出 toast "尚无活动会话",命令被拒绝。

回合中排队:

  1. 发送一条触发长时间回合的消息。
  2. 回合流式输出期间,输入 !echo queued 回车。
  3. 预期: 出现 info toast:"Shell 命令已排队,将在当前回合结束后执行。"
  4. 回合结束后,排队的 shell 命令自动执行,输出显示在聊天中。
  5. 回合中排多条 ! 命令——回合结束后按顺序逐条执行。

证据(前后对比)

N/A(需要运行中的 daemon 演示;已由单元测试覆盖)。

测试环境

OS 状态
🍏 macOS
🪟 Windows N/A
🐧 Linux N/A

环境(可选)

npx tsc --noEmit + npx vitest run client/App.test.tsx(173 个测试通过)。

风险与范围

  • 主要风险或权衡:新任务中即使只执行一条 shell 命令也会创建 session。这与普通消息行为一致,且复用 ensureSessionForPrompt() 的去重机制(createSessionPromiseRef),连续 ! 命令只创建一个 session。
  • 排队的 shell 命令存储在客户端 ref(queuedShellCommandsRef)中,页面刷新或切换 session 会丢失——对于即发即忘的 shell 命令来说可以接受。
  • 未验证 / 不在范围内:回合中并发执行 shell 命令(命令有意排队在回合结束后执行,而非并发——并发执行需要处理 promptStatus 覆写、Stop 按钮联动取消、并发输出渲染)。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

N/A

Previously, typing a ! command in a brand-new task showed "No active
session yet" because requireActiveSessionForLocalCommand() rejected
the command when no session existed. Replace it with
ensureSessionForPrompt() — the same lazy session creation path used
by regular messages — so the session is created on demand before the
shell command executes.
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed UX limitation — ! commands are rejected in new tasks ("No active session yet") and hard-blocked during turns with an error toast, while regular messages queue fine. The PR description clearly describes both restrictions and why they're inconsistent with the rest of the UI. No linked issue, but the behavior is self-evident from the code (queue.shellBlocked toast, requireActiveSessionForLocalCommand() guard).

Direction: aligned. Lazy session creation via ensureSessionForPrompt() already exists for regular messages — extending it to ! commands is the natural path. Client-side queuing during turns mirrors the existing useQueuedPrompts pattern. CHANGELOG: no direct reference, but Web Shell shell-command UX is squarely within scope.

Size: not applicable — changes are in packages/web-shell/client/ (not a core module path). Production: 222 lines (App.tsx 208, i18n.tsx 14). Tests: 1193 lines.

Approach: the scope feels right. The two changes (lazy creation + mid-turn queuing) are tightly coupled — both are about removing artificial restrictions on ! commands — and splitting them would create an awkward intermediate state. The drain machinery (generation counter, isDrainingRef, session-switch wipe) is the minimum needed to handle cancel/switch/disconnect safely. The scheduleDelayedSessionListReload extraction is a clean dedup of existing inline code. No unrelated changes spotted.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 UX 限制——! 命令在新任务中被拒绝("尚无活动会话"),在回合中被错误 toast 硬性拦截,而普通消息可以正常排队。PR 描述清楚地说明了两个限制及其与 UI 其余部分的不一致。无关联 issue,但行为从代码中即可确认(queue.shellBlocked toast、requireActiveSessionForLocalCommand() 守卫)。

方向:对齐。通过 ensureSessionForPrompt() 的懒创建 session 已用于普通消息——扩展到 ! 命令是自然路径。回合中客户端排队复用了现有 useQueuedPrompts 模式。CHANGELOG:无直接引用,但 Web Shell shell 命令 UX 完全在范围内。

规模:不适用——改动在 packages/web-shell/client/(非核心模块路径)。生产代码:222 行(App.tsx 208,i18n.tsx 14)。测试:1193 行。

方案:范围合理。两个改动(懒创建 + 回合中排队)紧密耦合——都是关于移除 ! 命令的人为限制——拆分它们会造成尴尬的中间状态。Drain 机制(generation 计数器、isDrainingRef、session 切换清空)是安全处理取消/切换/断连的最小需求。scheduleDelayedSessionListReload 提取是对现有内联代码的干净去重。未发现无关改动。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 3a1b506025f9f82574f6af6f48ea8c6d5707fdc1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: given the problem (shell commands blocked in new tasks and during turns), I would (1) reuse ensureSessionForPrompt() to lazily create a session before running the command in a new task, and (2) add a client-side ref queue that stores commands during a turn, draining them sequentially on the idle transition — with generation-based cancellation for session switch and user cancel. This is exactly what the PR does.

Comparison with the diff: the PR's approach matches my independent proposal. The implementation is clean and follows existing patterns:

  • Lazy session creation reuses ensureSessionForPrompt() (now returning string | undefined — the allocated session ID) and guards against duplicate submission with shellSubmitInFlightRef. The preparingSessionIdRef exemption in the session-switch effect is a subtle but essential detail — without it, the submit's own session creation would cancel the command.
  • Mid-turn queuing mirrors useQueuedPrompts: queue on submit during promptBlocked, drain on idle transition. The generation counter + isDrainingRef prevents competing drains and correctly handles cancel/switch/disconnect mid-drain. The batch loop re-reads the queue after each batch, so commands queued mid-drain are picked up without a second drain.
  • handleCancel clears the queue, bumps generation, and resets both isDrainingRef and shellSubmitInFlightRef — the latter is important so a retry after cancel during session creation isn't silently dropped.
  • scheduleDelayedSessionListReload is a clean extraction of existing inline code, now shared between the regular submit path and the new-task shell path.
  • i18n replaces queue.shellBlocked with queue.shellQueued (info) and queue.shellDropped (warning, pluralized). Both EN and ZH updated consistently.

No correctness bugs, security holes, or regressions found. No AGENTS.md violations — the code stays in packages/web-shell/client/, follows existing patterns, and doesn't over-abstract.

Test coverage is thorough: 20+ cases covering lazy creation, deduplication, FIFO drain, session switch, cancel (pre-drain and mid-drain), disconnect, error continuation, language change survival, generation staleness, retry after cancel, and the race between session-id render commit and attach resolution. The tests exercise the actual concurrent scenarios that make this feature non-trivial.

CI Test Evidence

All checks completed on 3a1b506025f9f82574f6af6f48ea8c6d5707fdc1:

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
Classify PR ✅ success
label ✅ success
review-scan ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (CLI, No Sandbox) ⏭️ skipped

macOS/Windows tests and integration tests are skipped (fork PR, expected). The unit suite and web-shell E2E smoke both pass. No failures.

Real-scenario testing: N/A for this CI run. The PR touches Web Shell UI behavior; a maintainer can check out the branch in a disposable environment to verify the new-task and mid-turn queuing flows interactively. The sandboxed @qwen-code /tmux lane is unavailable for this fork PR (author lacks write access).

中文说明

代码审查

独立方案: 给定问题(shell 命令在新任务和回合中被阻止),我会 (1) 复用 ensureSessionForPrompt() 在新任务中懒创建 session 后执行命令,(2) 添加客户端 ref 队列在回合中存储命令,在 idle 转换时按顺序 drain——用 generation 计数器处理 session 切换和用户取消。这正是 PR 所做的。

与 diff 的比较: PR 方案与我的独立提案一致。实现干净,遵循现有模式:

  • 懒创建 session 复用 ensureSessionForPrompt()(现返回 string | undefined——分配的 session ID),用 shellSubmitInFlightRef 防止重复提交。session 切换效果中的 preparingSessionIdRef 豁免是一个微妙但必要的细节——没有它,提交自身的 session 创建会取消命令。
  • 回合中排队 复用 useQueuedPrompts 模式:promptBlocked 时入队,idle 转换时 drain。generation 计数器 + isDrainingRef 防止竞争 drain,正确处理 drain 中的取消/切换/断连。批次循环在每批后重新读取队列,mid-drain 排队的命令被当前 drain 拾取,无需启动第二个 drain。
  • handleCancel 清空队列、递增 generation、重置 isDrainingRefshellSubmitInFlightRef——后者很重要,确保 session 创建期间取消后的重试不会被静默丢弃。
  • scheduleDelayedSessionListReload 是对现有内联代码的干净提取,现在在常规提交路径和新任务 shell 路径之间共享。
  • i18nqueue.shellQueued(info)和 queue.shellDropped(warning,复数化)替换 queue.shellBlocked。中英文一致更新。

未发现正确性 bug、安全漏洞或回归。无 AGENTS.md 违规——代码在 packages/web-shell/client/ 内,遵循现有模式,无过度抽象。

测试覆盖 全面:20+ 用例覆盖懒创建、去重、FIFO drain、session 切换、取消(drain 前和 drain 中)、断连、错误继续、语言切换存活、generation 过期、取消后重试、以及 session-id 渲染提交与 attach 解析之间的竞态。测试覆盖了使此功能非平凡的实际并发场景。

CI 测试证据

3a1b506025f9f82574f6af6f48ea8c6d5707fdc1 上所有检查已完成。单元测试套件和 web-shell E2E smoke 均通过。macOS/Windows 测试和集成测试已跳过(fork PR,预期行为)。无失败。

真实场景测试: 本次 CI 运行不适用。PR 触及 Web Shell UI 行为;维护者可在隔离环境中 checkout 分支以交互验证新任务和回合中排队流程。沙箱 @qwen-code /tmux 通道对此 fork PR 不可用(作者无写权限)。

Qwen Code · qwen3.8-max-preview

Reviewed at 3a1b506025f9f82574f6af6f48ea8c6d5707fdc1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid, well-tested implementation that reuses existing infrastructure cleanly; the drain machinery is the minimum complexity the concurrent scenarios require.

Stepping back: this PR removes two artificial restrictions on ! commands that were inconsistent with how regular messages work. The lazy session creation reuses ensureSessionForPrompt() — the same path regular messages take — and the mid-turn queue mirrors useQueuedPrompts. Neither adds a new abstraction; both extend what's already there.

The drain loop is the most complex part, and it earns its complexity: cancel mid-drain, session switch mid-drain, disconnect, commands queued while a drain is running — each is a real scenario a user can hit, and each is handled with the generation counter + isDrainingRef pattern. The test suite exercises all of these, including the subtle race where the session-id render commit lands before attachSession resolves.

After 8 review rounds from the author and multiple autofix iterations, the earlier blocking findings (drain machinery robustness, cancel/switch edge cases) have been addressed. The diff is focused — every change serves the stated goal, no drive-by refactors.

Not 5/5 only because four mutable refs for queue management in an already-large App component is a lot of state to reason about — but that's inherent to the feature, not a design flaw. If I had to maintain this in six months, the generation-counter pattern and the thorough test suite would make it straightforward.

CI is green on the reviewed commit. Approving. ✅

中文说明

置信度:4/5 —— 实现扎实、测试充分,干净地复用了现有基础设施;drain 机制是并发场景所需的最小复杂度。

退一步看:这个 PR 移除了 ! 命令的两个人为限制,它们与普通消息的行为不一致。懒创建 session 复用了 ensureSessionForPrompt()——与普通消息相同的路径——回合中队列复用了 useQueuedPrompts。两者都没有添加新抽象,都是对现有功能的扩展。

Drain 循环是最复杂的部分,但其复杂度是必要的:drain 中取消、drain 中切换 session、断连、drain 运行时排队新命令——每个都是用户可能遇到的真实场景,每个都通过 generation 计数器 + isDrainingRef 模式处理。测试套件覆盖了所有这些,包括 session-id 渲染提交在 attachSession 解析之前落地的微妙竞态。

经过作者 8 轮审查和多次 autofix 迭代,早期的阻塞性发现(drain 机制健壮性、取消/切换边界情况)已得到解决。Diff 聚焦——每个改动都服务于既定目标,无顺手重构。

未给 5/5 仅因为在一个已经很大的 App 组件中用四个可变 ref 管理队列状态需要较多推理——但这是功能本身决定的,不是设计缺陷。如果六个月后维护这段代码,generation 计数器模式和全面的测试套件会让工作变得简单。

CI 在审查的提交上全绿。批准。✅

Qwen Code · qwen3.8-max-preview

Reviewed at 3a1b506025f9f82574f6af6f48ea8c6d5707fdc1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

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

Screenshots · before / after

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

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

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

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

Qwen Code · web-shell visuals

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

@gwinthis

Copy link
Copy Markdown
Collaborator

Review + verification report (Linux, built workspace, jsdom component harness)

Verdict: the fix is correct and minimal — I proved the behavior delta with a probe test against the real App component (passes on this PR, fails on base), and the full App suite stays green. One suggestion: this PR should ship a test, and the probe below is ready to adopt.

Code review reasoning

  1. It reuses the exact lazy-session mechanism the prompt path already trusts. ensureSessionForPrompt() is single-flighted (createSessionPromiseRef — concurrent calls share one promise), so a rapid second ! command cannot create a duplicate session. This is the same call sendPrompt awaits at its own entry, so the ! path now inherits proven semantics instead of a parallel gate.
  2. Session targeting is correct by construction. sendShellCommand resolves sessionRef.current at call time (requireSessionForAction in the daemon session actions), and ensureSessionForPrompt attaches the new session before resolving — so the command runs on the just-created session, same as the first prompt does.
  3. The stricter guards are preserved. The busy-turn branch (promptBlocked → "Shell commands can't be queued" toast) still runs before the change, and empty commands still bail. Only the "no session yet" rejection is replaced with creation.
  4. Hook hygiene: ensureSessionForPrompt is added to the callback's dependency array — correct.

Verification evidence (commit 229276e, workspace built from source)

I inserted a probe test into the existing App.test.tsx harness (drives the real component's submit path via latestChatEditorProps.onSubmit):

it('lazily creates a session for ! shell commands in a new task', async () => {
  mockConnection.sessionId = undefined;
  renderApp();
  await flush();

  await act(async () => {
    testState.latestChatEditorProps?.onSubmit('!echo hi');
    await vi.waitFor(() => {
      expect(mockSessionActions.sendShellCommand).toHaveBeenCalledWith('echo hi');
    });
  });
  expect(mockSessionActions.createSession).toHaveBeenCalled();
});
Run Result
Probe on this PR ✅ passes — session created, sendShellCommand('echo hi') invoked
Probe on base App.tsx (merge-base) ❌ fails — sendShellCommand never called (old gate rejected the input)
Full App.test.tsx suite on this PR 174/174 passed — no regressions in the submit/queue/workspace flows

Suggestion (non-blocking)

The PR description says the behavior is "covered by unit tests", but the diff contains no test. Since a session-less ! submit regressing back to a toast would be silent, please add a pinning test — the probe above drops straight into the App session callbacks describe block and needs no new harness.

(Scope note: no browser was available in this environment, so verification used the component-level harness rather than a click-through; the harness drives the same onSubmit code path the composer uses.)

When a turn is running, ! commands are now queued client-side and
executed sequentially after the turn finishes, instead of showing
an error toast. A drain effect watches streamingState returning to
idle and runs pending commands via sendShellCommand(). The user
gets an info toast confirming the command was queued.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ This run could not certify that any of this diff was reviewed. Suggestions are inline. Not reviewed: coverage — no plan was given, so this run cannot show that any of the diff was read.

— qwen3.7-max via Qwen Code /review

Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/App.tsx Outdated
Comment on lines +5877 to +5879
queuedShellCommandsRef.current.push(cmd);
pushToast('info', t('queue.shellQueued'));
return true;

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.

[Suggestion] Queued shell commands have no user visibility or cancellation mechanism, unlike the existing prompt queue which renders in QueuedPromptDisplay with delete/edit/insert controls. — Failure scenario: User types !rm -rf build/ during a long turn. A transient info toast appears and disappears. The user forgets. When the turn ends, the drain effect auto-executes the destructive command with no confirmation and no way to cancel. Regular prompts queued during a turn are displayed with onDelete, onInsert, onEdit — shell commands bypass this system entirely.

中文说明

[Suggestion] 排队的 shell 命令没有用户可见性或取消机制,而现有的 prompt 队列通过 QueuedPromptDisplay 提供删除/编辑/插入控件。—— 失败场景:用户在长时间回合中输入 !rm -rf build/,出现一个短暂的 info toast 后消失。用户忘记了。回合结束后,drain effect 自动执行该破坏性命令,没有确认也没有取消方式。回合中排队的普通 prompt 会显示 onDeleteonInsertonEdit 控件 —— shell 命令完全绕过了这套系统。

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@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 Jul 25, 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. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@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 review round — PR #7724

Addressed the automated reviewer's findings on the shell-command queueing change. One correctness fix, four small UX/consistency improvements that mirror the existing prompt path, and new test coverage. One finding is left open for a maintainer decision (below). No conflicts (--conflict false), so no merge was performed.

Findings addressed

  • [rc:3650805617] Queued shell commands were never cleared on session change — implemented (correctness/safety). queuedShellCommandsRef survived a session switch, so a command queued during a turn in Session A could be drained and executed against Session B's daemon (a different workspace) once Session B went idle. Added an effect that wipes the queue when connection.sessionId changes, declared before the drain effect so the wipe always runs first in the same commit. This mirrors useQueuedPrompts, which clears its queue on sessionId change.
  • [rc:3650642653] / [rc:3650805620] No test coverage for the ! paths — implemented. Added an App shell command queueing suite in App.test.tsx covering: (1) !echo hi with no active session lazily creates a session then runs the command; (2) session-creation failure reports an error and does not run the command; (3) !cmd during a turn is queued (info toast) and drains when the turn ends; (4) queued commands are dropped on a session switch. The session-switch test was confirmed to fail without the fix and pass with it.
  • [rc:3650642654] Misleading error message — implemented. The catch now reports Failed to create session for shell command when no session exists at failure time, and Failed to execute shell command otherwise, so a session-creation failure is no longer reported as a command-execution failure.
  • [rc:3650642656] No feedback during async session creation — implemented. When the ! path has to create a session, it now sets isPreparingPrompt for the duration (cleared in a finally), matching the regular prompt path. It is gated on "no session yet" so an existing-session command does not flash the indicator.
  • [rc:3650642658] Sidebar not refreshed after creating a session — implemented. When the ! path actually creates a new session, it now bumps the session-list reload token immediately and schedules the same 2-second delayed reload sendPrompt uses, so the new session appears in the sidebar without waiting for the next action.

Open question for a maintainer (left unresolved)

  • [rc:3650805621] Queued shell commands have no visibility/cancellation UI. Unlike queued prompts (which render in QueuedPromptDisplay with delete/edit/insert controls), a queued shell command only surfaces as a transient info toast and then auto-executes when the turn ends. Whether queued shell commands should get a visible, cancellable queue UI is a product/scope tradeoff rather than a defect with a single correct answer, so I am leaving it for a maintainer rather than settling it either way.
    • Options: (a) keep the current toast-only behavior for v1 (smallest scope; the cross-workspace execution risk is already closed by rc:3650805617); (b) render queued shell commands in a lightweight list with a cancel button; (c) reuse/extend QueuedPromptDisplay so shell commands get the same delete/edit/insert controls as prompts (largest scope).
    • My recommendation: (a) for this PR, with (b)/(c) tracked as a follow-up if maintainers want queue management for shell commands — this keeps the focused fix small while not closing the door on the UX.

Conflict notes

None — --conflict false; origin/main was not merged.

Verification

  • npm run typecheck — passed
  • npm run lint — passed
  • npm run build — passed
  • npx vitest run client/App.test.tsx (touched package, packages/web-shell) — 177 passed (173 existing + 4 new)
  • Negative check — temporarily removed the session-change clear effect: drops queued ! commands when the session changes failed; restored the effect and it passed, confirming the test pins the fix
  • Settings schema — not regenerated (no settings source changed); integration tests after npm run bundle — not run (the touched behavior is web-shell client UI exercised through the jsdom component harness, not the bundled CLI)
中文说明

Autofix 审查轮次 — PR #7724

处理了自动审查器针对 shell 命令排队改动提出的意见。包含一处正确性修复、四处镜像现有 prompt 路径的小型 UX/一致性改进,以及新增的测试覆盖。有一项发现留给维护者决定(见下文)。无冲突(--conflict false),因此未执行合并。

已处理的发现

  • [rc:3650805617] 排队的 shell 命令在切换会话时从不清空 — 已实现(正确性/安全性)。 queuedShellCommandsRef 在切换会话后仍然保留,因此在 Session A 的回合中排队的命令,可能在 Session B 进入空闲时被 drain 并在 Session B 的 daemon(另一个工作区)上执行。新增了一个在 connection.sessionId 变化时清空队列的 effect,并声明在 drain effect 之前,以保证在同一次提交中清空总是先执行。这与 useQueuedPromptssessionId 变化时清空队列的行为一致。
  • [rc:3650642653] / [rc:3650805620] ! 路径缺少测试覆盖 — 已实现。App.test.tsx 中新增了 App shell command queueing 测试套件,覆盖:(1) 无活跃会话时输入 !echo hi 会延迟创建会话然后执行命令;(2) 会话创建失败时上报错误且不执行命令;(3) 回合中输入 !cmd 会排队(info toast)并在回合结束后 drain;(4) 切换会话时排队的命令会被丢弃。其中切换会话的测试已确认在缺少修复时失败、加上修复后通过。
  • [rc:3650642654] 错误信息误导 — 已实现。 catch 现在会在失败时没有会话的情况下上报 Failed to create session for shell command,否则上报 Failed to execute shell command,从而不会把会话创建失败误报为命令执行失败。
  • [rc:3650642656] 异步创建会话期间无反馈 — 已实现。! 路径需要创建会话时,现在会在这段时间设置 isPreparingPrompt(在 finally 中清除),与常规 prompt 路径一致。它仅在“尚无会话”时启用,因此已有会话的命令不会闪烁该指示器。
  • [rc:3650642658] 创建会话后侧边栏未刷新 — 已实现。! 路径确实创建了新会话时,现在会立即递增会话列表刷新 token,并安排与 sendPrompt 相同的 2 秒延迟刷新,使新会话无需等待下一次操作就出现在侧边栏中。

留给维护者的开放问题(保持未解决)

  • [rc:3650805621] 排队的 shell 命令没有可见性/取消 UI。 与排队的 prompt(在 QueuedPromptDisplay 中渲染并提供删除/编辑/插入控件)不同,排队的 shell 命令只会以一个短暂的 info toast 呈现,然后在回合结束时自动执行。排队的 shell 命令是否应获得可见、可取消的队列 UI,是一个产品/范围权衡,而非只有唯一正确答案的缺陷,因此我将其留给维护者,而不擅自决定。
    • 选项:(a) 在 v1 中保持当前仅 toast 的行为(范围最小;跨工作区执行风险已由 rc:3650805617 关闭);(b) 用一个轻量列表渲染排队的 shell 命令并提供取消按钮;(c) 复用/扩展 QueuedPromptDisplay,使 shell 命令获得与 prompt 相同的删除/编辑/插入控件(范围最大)。
    • 我的建议:本 PR 采用 (a),如果维护者希望为 shell 命令提供队列管理,则以 (b)/(c) 作为后续跟进——这样既保持聚焦修复的小巧,又不关闭该 UX 的可能性。

冲突说明

无 — --conflict false;未合并 origin/main

验证

  • npm run typecheck — 通过
  • npm run lint — 通过
  • npm run build — 通过
  • npx vitest run client/App.test.tsx(受影响包,packages/web-shell)— 177 通过(173 个已有 + 4 个新增)
  • 反向验证 — 临时移除切换会话的清空 effect:drops queued ! commands when the session changes 失败;恢复该 effect 后通过,确认该测试锁定了修复
  • 设置 schema — 未重新生成(未改动任何设置源);npm run bundle 后的集成测试 — 未运行(受影响行为是 web-shell 客户端 UI,通过 jsdom 组件测试框架验证,而非打包后的 CLI)

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

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


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

中文说明

已审查——无阻断问题。 建议见行内评论。 1 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。

— qwen3.7-max via Qwen Code /review

Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/web-shell/client/App.tsx Outdated
@wenshao

wenshao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Review: fix(web-shell): allow shell commands in new tasks without a session

Overview

Two independent UX changes to the ! branch of handleSubmit in packages/web-shell/client/App.tsx:

  1. New task!cmd with no session now goes through ensureSessionForPrompt() (lazy session creation) instead of being rejected by requireActiveSessionForLocalCommand().
  2. During a turn!cmd is pushed onto a client-side queuedShellCommandsRef and drained by an effect when streamingState returns to 'idle', instead of being hard-blocked with an error toast. queue.shellBlockedqueue.shellQueued in EN/ZH.

Commit da71f01 adds a third effect that wipes the queue on connection.sessionId change.

What I verified locally (worktree at da71f01a)

Check Result
npx vitest run client/App.test.tsx ✅ 177/177 pass
npx tsc --noEmit -p packages/web-shell/tsconfig.json ✅ clean
npx prettier --check on the 3 changed files ✅ clean
Mutation: neuter the session-switch clear effect drops queued ! commands when the session changes fails → test is load-bearing
Mutation: return early in the drain effect queues a ! command during a turn and drains it fails → test is load-bearing
Dangling queue.shellBlocked references ✅ none remain

The design is sound in one important respect worth calling out: promptBlocked is defined as exactly streamingStateRef.current !== 'idle' (App.tsx:4933), and the drain guard is exactly streamingState !== 'idle'. Because the enqueue predicate and the drain predicate are the same condition, there is no "queued but never drained" class of bug — a common failure mode for this pattern. The ref-sync effect (streamingStateRef.current = streamingState) is also declared before the drain effect, so the ref can't be stale at drain time.


🔴 Blocking: a mid-drain session switch runs the remaining commands against the new session

The clear-on-switch effect only protects commands still sitting in queuedShellCommandsRef. The drain empties the ref synchronously and then iterates a local cmds array with await between each command:

queuedShellCommandsRef.current = [];
void (async () => {
  for (const cmd of cmds) {
    try {
      await sessionActions.sendShellCommand(cmd);   // <- resolves the session lazily, each time

sendShellCommand resolves sessionRef.current at call time (packages/webui/src/daemon/session/actions.ts:1094), not at effect time. So once the loop is running, every command after the first targets whichever session is current at that moment — potentially a different workspace's daemon and cwd. The window is as wide as the slowest command in the queue (!npm run build, !pytest, …).

Verified with a probe test appended to App.test.tsx on the PR branch: queue !first + !second during a turn; go idle so the drain starts and blocks on a pending first; switch connection.sessionId to session-2; resolve first. Result:

PROBE calls: [["first"],["second"]]

second is dispatched after the switch. This is the same hazard da71f01 set out to close — it only closed the pre-drain half.

Suggested fix — pin the session for the whole drain:

useEffect(() => {
  if (streamingState !== 'idle') return;
  const cmds = queuedShellCommandsRef.current;
  if (cmds.length === 0) return;
  queuedShellCommandsRef.current = [];
  const drainSessionId = connectionRef.current.sessionId;
  void (async () => {
    for (const cmd of cmds) {
      // The session can change while an earlier command is still running.
      if (connectionRef.current.sessionId !== drainSessionId) return;
      try {
        await sessionActions.sendShellCommand(cmd);
      } catch (error: unknown) {
        reportError(error, 'Failed to execute shell command');
      }
    }
  })();
}, [streamingState, sessionActions, reportError]);

connectionRef.current is assigned during render (App.tsx:2940), so it is always current. Please add a regression test for the mid-drain case alongside the existing pre-drain one.


🟠 Queued shell commands are invisible and uncancellable

The PR's stated rationale is parity with regular queued messages, but the two differ in a way that matters:

  • Queued prompts render in the queued-messages list with a per-item delete (ChatPane.tsx:654-656, prompts={queuedPrompts} onDelete={removeQueuedPrompt}).
  • Queued shell commands live only in a ref. No list, no delete, no count. The only feedback is one transient toast.

Concretely: a user starts a long turn, types !rm -rf build/ (the PR's own test fixture), then thinks better of it and presses Stop. handleCancelsessionActions.cancel() aborts the prompt controller keyed on session.sessionId (actions.ts:465); the shell entry is keyed ${sessionId}:shell and the client-side queue isn't touched at all. The turn goes idle → the drain fires → the command the user tried to abort executes anyway.

At minimum this deserves a decision in the PR: either surface queued shell commands in the existing queued-messages UI, or drop the queue on cancel. Silently deferring destructive commands past a Stop is the part I'd push back on hardest after the blocker above.

Related, smaller: the queue is unbounded and undeduplicated — hold Enter on !ls during a long turn and you get N toasts and N sequential executions when it ends.


🟡 Smaller notes

  • Copy-paste from sendPrompt. The setSessionListReloadToken + 2 s delayedReloadTimerRef block is duplicated verbatim from App.tsx:3171-3179. Both sites share the same timer ref, so they already interact; extracting a scheduleSessionListReload() helper would keep them from drifting.
  • Error label can mislead. The catch reads connectionRef.current.sessionId at rejection time. connectionRef is updated during render, but the webui provider's sessionRef is updated synchronously on attach — so a sendShellCommand failure that lands before React re-renders reports 'Failed to create session for shell command'. Capturing the outcome of ensureSessionForPrompt() in a local (e.g. a let created = false set in the .then) would make the label exact.
  • No unmount cleanup on the drain. A tab close mid-drain leaves the loop running. Low impact, but a let cancelled = false + cleanup would match the surrounding effect style and would fall out of the blocker fix anyway.
  • i18n. EN and ZH updated symmetrically and no queue.shellBlocked reference survives. Note Messages is Record<string, MessageValue> and check-i18n only covers packages/cli, so web-shell key drift isn't CI-gated — worth remembering, nothing to change here.

Test coverage

Good density for the happy paths, and both new effects are mutation-verified as load-bearing. Gaps: (a) the mid-drain switch above, (b) ordering with more than one queued command (the drain claims sequential FIFO but only ever exercises a single command), (c) drain-time failure — sendShellCommand rejecting on command 1 should still run command 2, which the code does but nothing asserts.

Verdict

The direction is right and the state machine is cleaner than I expected — the enqueue/drain predicates being literally the same expression removes a whole class of bugs. Please fix the mid-drain session pinning before merge, and make a deliberate call on the Stop-vs-queue semantics.

中文说明

概述

本 PR 修改 App.tsxhandleSubmit! 分支:(1) 新任务中 ! 命令改走 ensureSessionForPrompt() 懒创建 session;(2) 回合运行期间不再硬拦截,而是压入客户端 queuedShellCommandsRef,待 streamingState 回到 idle 后由 effect 顺序 drain。第三个提交新增了「切换 session 时清空队列」的 effect。

本地验证结果(worktree @ da71f01a

  • vitest run client/App.test.tsx:177/177 通过
  • tsc --noEmit:干净;prettier --check:干净
  • 变异测试:禁用「切换清空」effect → 对应用例失败;禁用 drain → 对应用例失败。两个新 effect 均被测试真实覆盖。
  • 无遗留的 queue.shellBlocked 引用。

值得肯定的一点:promptBlocked 的定义正是 streamingState !== 'idle'App.tsx:4933),与 drain 的判据完全一致,因此不存在「入队后永不 drain」这类常见缺陷;ref 同步 effect 也声明在 drain 之前,不会读到陈旧值。

🔴 阻塞问题:drain 过程中切换 session,剩余命令会打到新 session

清空 effect 只保护「尚未开始 drain」的队列。drain 会先同步清空 ref,再对本地数组逐条 await。而 sendShellCommand 是在调用时刻解析 sessionRef.currentactions.ts:1094),并非 effect 建立时绑定。因此一旦循环开始,第一条之后的命令会打到「当时」的 session —— 可能是另一个 workspace 的 daemon 和 cwd。窗口期等于队列中最慢那条命令的耗时。

已用探针测试在 PR 分支上确认:回合中排入 !first!second,转 idle 后 drain 阻塞在 pending 的 first,此时把 connection.sessionId 切到 session-2,再放行 first —— 结果 PROBE calls: [["first"],["second"]]second 在切换之后仍被派发。

建议在 drain 开始时固定 session(正文中已给出补丁),并补一条针对 drain 中途切换的回归测试。

🟠 排队的 shell 命令不可见、不可取消

普通排队消息在 ChatPane.tsx:654-656 有列表与逐条删除按钮;排队的 shell 命令只存在于 ref 中,无列表、无删除、无计数,仅有一次瞬时 toast。

具体场景:用户在长回合中输入 !rm -rf build/(正是本 PR 测试里用的命令),随后反悔按 Stopcancel() 中止的是以 session.sessionId 为 key 的 controller(actions.ts:465),shell 用的是 ${sessionId}:shell,客户端队列更是完全不受影响。回合转 idle 后 drain 照常执行 —— 用户试图取消的命令依然运行了。

建议二选一:把排队的 shell 命令并入现有排队消息 UI,或在 cancel 时丢弃队列。另外队列无上限也无去重。

🟡 其他

  • setSessionListReloadToken + 2 秒 delayedReloadTimerRef 逻辑与 App.tsx:3171-3179 完全重复,两处共用同一个 timer ref,建议抽成 scheduleSessionListReload()
  • catch 中的错误文案在 reject 时刻读 connectionRef.current.sessionId,可能把「命令执行失败」误报成「创建 session 失败」;建议用局部变量记录 ensureSessionForPrompt() 的结果。
  • drain 没有 unmount 清理。
  • EN/ZH 对称更新,无遗留 key;但 MessagesRecord<string, MessageValue>check-i18n 只覆盖 packages/cli,web-shell 的 key 漂移没有 CI 兜底。

测试覆盖

新增用例均为「有效测试」(变异可杀)。缺口:drain 中途切换 session、多条命令的顺序性(当前只测了单条)、drain 中第一条失败后第二条仍应执行。

结论

方向正确,状态机比预期更干净。合并前请修复 drain 期间的 session 固定问题,并就 Stop 与队列的语义做一个明确决定。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Review feedback addressed

🔴 Mid-drain session switch (blocking, @wenshao)

Decision: Implemented.

The drain effect now pins connectionRef.current.sessionId at effect time (drainSessionId) and checks it before each command in the loop. If the session changes mid-drain, the remaining commands are silently dropped. Also added a cancelled flag with effect cleanup to handle unmount during a drain.

Regression test added: aborts remaining commands if the session changes mid-drain — queues two commands, starts the drain, switches session while the first command is pending, then asserts the second command is never dispatched.

🟠 Queued shell commands invisible/uncancellable (@wenshao)

Decision: Implemented the minimal safe fix — drop the queue on cancel.

handleCancel now clears queuedShellCommandsRef before calling sessionActions.cancel(), so pressing Stop discards any queued shell commands and they will not execute when the turn goes idle. Test added: drops queued ! commands when the user cancels.

The broader UI visibility question (surfacing queued shell commands in the queued-messages list with per-item delete) and the unbounded/undeduplicated queue note are left as potential follow-up work — they are larger scope and not required for the safety guarantee.

🟡 Copy-paste from sendPrompt (@wenshao) + session-list reload duplication ([rc:3651416710])

Decision: Implemented.

Extracted scheduleDelayedSessionListReload() — a stable useCallback that clears any pending timer and schedules a 2-second backup reload. Both sendPrompt and the shell command path now call this helper instead of duplicating the inline pattern.

🟡 Missing dispatchSessionChangeRef call ([rc:3651416710])

Decision: Implemented.

The shell command path now calls dispatchSessionChangeRef.current?.({type: 'submit', ...}) when a new session is lazily created, matching the sendPrompt behavior. Embedding apps relying on onSessionChange for analytics or sidebar highlighting will now be notified about sessions created by ! commands.

🟡 Error label can mislead (@wenshao)

Decision: Implemented.

Replaced the connectionRef.current.sessionId check in the catch handler with a local sessionCreated flag set inside .then(). The error message is now needsSession && !sessionCreated ? 'Failed to create session for shell command' : 'Failed to execute shell command', which is exact regardless of React render timing.

🟡 No unmount cleanup on drain (@wenshao)

Decision: Implemented as part of the blocking fix.

The drain effect returns a cleanup function that sets cancelled = true, stopping the loop if the component unmounts mid-drain.

🟡 i18n (@wenshao)

Decision: Acknowledged, no change needed. EN/ZH are symmetric and no stale keys remain.

Test coverage gaps (@wenshao) + drain error recovery ([rc:3651416713])

Decision: All three gaps addressed with new tests:

  • (a) Mid-drain switch: aborts remaining commands if the session changes mid-drain
  • (b) FIFO ordering: drains multiple queued commands in FIFO order — queues three commands and asserts sequential FIFO dispatch
  • (c) Drain error recovery: continues draining after a command fails — first command rejects, second still executes, reportError called once

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check on changed files — passed
  • npx vitest run client/App.test.tsx (packages/web-shell) — 181 passed (177 existing + 4 new)
中文说明

已处理的审查反馈

🔴 drain 过程中切换 session(阻塞问题,@wenshao

决定: 已实现。

drain effect 现在在 effect 建立时固定 connectionRef.current.sessionIddrainSessionId),并在循环中每条命令执行前检查。如果 session 在 drain 期间发生切换,剩余命令将被静默丢弃。同时增加了 cancelled 标志和 effect 清理函数,处理 drain 期间组件卸载的情况。

新增回归测试:aborts remaining commands if the session changes mid-drain —— 排入两条命令,启动 drain,在第一条命令 pending 时切换 session,然后断言第二条命令不会被派发。

🟠 排队的 shell 命令不可见、不可取消(@wenshao

决定: 实现了最小安全修复 —— 取消时丢弃队列。

handleCancel 现在在调用 sessionActions.cancel() 之前清空 queuedShellCommandsRef,因此按 Stop 会丢弃所有排队的 shell 命令,回合转 idle 后不会执行。新增测试:drops queued ! commands when the user cancels

更广泛的 UI 可见性问题(在排队消息列表中显示排队的 shell 命令并支持逐条删除)以及队列无上限/无去重的问题留作后续跟进 —— 它们范围更大,且不影响安全保证。

🟡 sendPrompt 中的复制粘贴(@wenshao)+ session 列表刷新逻辑重复([rc:3651416710])

决定: 已实现。

提取了 scheduleDelayedSessionListReload() —— 一个稳定的 useCallback,清除已有定时器并安排 2 秒后的备份刷新。sendPrompt 和 shell 命令路径现在都调用此 helper,不再重复内联模式。

🟡 缺少 dispatchSessionChangeRef 调用([rc:3651416710])

决定: 已实现。

shell 命令路径现在在懒创建新 session 时调用 dispatchSessionChangeRef.current?.({type: 'submit', ...}),与 sendPrompt 行为一致。依赖 onSessionChange 做分析或侧边栏高亮的嵌入应用现在能收到 ! 命令创建 session 的通知。

🟡 错误标签可能误导(@wenshao

决定: 已实现。

将 catch 中的 connectionRef.current.sessionId 检查替换为在 .then() 中设置的局部 sessionCreated 标志。错误消息现在是 needsSession && !sessionCreated ? 'Failed to create session for shell command' : 'Failed to execute shell command',不受 React 渲染时序影响。

🟡 drain 没有 unmount 清理(@wenshao

决定: 作为阻塞问题修复的一部分已实现。

drain effect 返回清理函数,设置 cancelled = true,在组件于 drain 期间卸载时停止循环。

🟡 i18n(@wenshao

决定: 已确认,无需修改。EN/ZH 对称更新,无遗留 key。

测试覆盖缺口(@wenshao)+ drain 错误恢复([rc:3651416713])

决定: 三个缺口均已用新测试覆盖:

  • (a) drain 中途切换: aborts remaining commands if the session changes mid-drain
  • (b) FIFO 顺序: drains multiple queued commands in FIFO order —— 排入三条命令,断言按 FIFO 顺序逐条派发
  • (c) drain 错误恢复: continues draining after a command fails —— 第一条命令 reject,第二条仍然执行,reportError 被调用一次

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • npx prettier --check(改动文件)— 通过
  • npx vitest run client/App.test.tsx(packages/web-shell)— 181 通过(177 已有 + 4 新增)

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

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


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.7-max via Qwen Code /review

Comment thread packages/web-shell/client/App.tsx
Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/web-shell/client/App.test.tsx
@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 feedback addressed

[rc:3651619941] handleCancel cannot stop an active drain — Implemented

Added a drainCancelledRef that handleCancel sets to true. The drain effect resets it to false at the start of each drain cycle and checks it in the loop guard alongside the existing cancelled closure and session-id check. This closes the gap where pressing Stop mid-drain had no effect because sessionActions.cancel() targets a different controller key than sendShellCommand.

Changes: App.tsx — added drainCancelledRef declaration, reset in drain effect, check in loop guard, set in handleCancel.

[rc:3651619948] Silent drop of queued commands on session switch — Implemented

Added a console.warn in the drain loop's early-return path that logs how many commands are being dropped. Switched the loop from for...of to an indexed for loop so the remaining count is available without indexOf (which would be fragile with duplicate commands).

Changes: App.tsx — indexed loop + console.warn('[web-shell] dropping %d queued shell command(s)', ...).

[rc:3651619950] Missing mid-drain cancel test — Implemented

Added a test that queues two commands, goes idle (drain starts, blocks on the pending first), calls onCancel(), resolves the first command, and asserts the second is never dispatched. Mirrors the existing mid-drain session-switch test pattern.

Changes: App.test.tsx — new test 'stops draining remaining commands when the user cancels mid-drain'.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx vitest run client/App.test.tsx (packages/web-shell) — 182 passed (182)
中文说明

已处理的审查反馈

[rc:3651619941] handleCancel 无法停止正在执行的 drain — 已实现

新增 drainCancelledRef,由 handleCancel 设置为 true。drain effect 在每次 drain 周期开始时将其重置为 false,并在循环守卫中与现有的 cancelled 闭包和 session-id 检查一起使用。这修复了用户在 drain 过程中按下停止键无效的问题,因为 sessionActions.cancel() 中止的控制器 key 与 sendShellCommand 注册的不同。

变更: App.tsx — 新增 drainCancelledRef 声明、在 drain effect 中重置、在循环守卫中检查、在 handleCancel 中设置。

[rc:3651619948] session 切换时静默丢弃排队命令 — 已实现

在 drain 循环的提前返回路径中添加了 console.warn,记录被丢弃的命令数量。将循环从 for...of 改为索引 for 循环,以便直接获取剩余数量,避免使用对重复命令不可靠的 indexOf

变更: App.tsx — 索引循环 + console.warn('[web-shell] dropping %d queued shell command(s)', ...)

[rc:3651619950] 缺少 mid-drain 取消测试 — 已实现

新增测试:排入两条命令,转为 idle(drain 开始,阻塞在第一条),调用 onCancel(),放行第一条命令,断言第二条不会被派发。与现有的 mid-drain session 切换测试模式一致。

变更: App.test.tsx — 新增测试 'stops draining remaining commands when the user cancels mid-drain'

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • npx vitest run client/App.test.tsx(packages/web-shell)— 182 通过(182)

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

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


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

Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/web-shell/client/App.tsx Outdated
@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 将重新运行。

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

[Critical] packages/web-shell/client/App.tsx:3884-3917 — existing Critical (comment 3651817795) still stands: the drain effect's dependency on streamingState causes self-cancellation when sendShellCommand sets promptStatus('waiting'), which changes streamingState from 'idle' to 'waiting', triggering the effect cleanup (cancelled = true). After the first command's await resolves, the loop exits and remaining queued commands are silently dropped. The FIFO drain test does not catch this because sendShellCommand is mocked with mockResolvedValue(undefined) which never triggers any promptStatus change.

— qwen3.7-max via Qwen Code /review

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

wenshao commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Review — R7 @ b0a7c519

Scope: packages/web-shell/client/{App.tsx,App.test.tsx,i18n.tsx} (+1069/−23).
Method: isolated worktree at the PR head, full client/App.test.tsx suite (194/194 green), a 15-mutant matrix over every new guard, plus targeted A/B probes. 11/15 mutants killed.

✅ The R6 blocker is fixed, and the fix is genuinely covered

c51919b adds the lazy-creation exemption at App.tsx:3897, so a submit's own undefined → 'session-1' transition no longer bumps drainGenerationRef and cancels its own ! command.

I re-ran the R6 mutant (if (connection.sessionId !== preparingSessionIdRef.current)if (true)) — it is killed by runs the ! command in a new task even when the session-id render commits before attach resolves. The adopted test crosses act() boundaries, so it is not blind to the commit/passive-effect ordering the way the old tests were. The blocker is closed for the right reason.

Also good: FIFO order, the mid-drain re-read, the duplicate-submit guard, the cancel/session-switch wipes, and return !needsSession are all mutation-covered; i18n has queue.shellQueued/queue.shellDropped in both EN and ZH with no leftover queue.shellBlocked.


🔴 Blocker — the disconnect drop-path leaks the queue and breaks FIFO

App.tsx:3931-3943. The drain bails for three reasons. Two of them — a generation bump from the session-switch effect (3892-3893) or from handleCancel (6093-6094) — wipe queuedShellCommandsRef at the bump site. The third, connectionRef.current.status !== 'connected' (3934), does not. Anything queued mid-drain and picked up by f182512's batch re-read (3950) survives the bail.

Reproduced at head (probe, not speculation): queue a,b during a turn → drain starts on a → user queues c while a runs → connection drops → a resolves.

after-drop calls = [["a"]]
toasts           = [["warning","1 queued shell command will not run."]]
--- reconnect + one non-idle→idle transition ---
final calls      = [["a"],["c"]]

The user queued a, b, c. a ran, b was discarded with "will not run", and c then ran anyway — the older command is dropped while the newer one executes, out of order, against a connection that dropped in between. c is never counted in the dropped toast either, so the report under-states the loss.

Fix (verified: red before / green after, and full suite 195/195 with the added test):

-              const dropped = batch.length - i;
+              const dropped =
+                batch.length - i + queuedShellCommandsRef.current.length;
+              queuedShellCommandsRef.current = [];
               console.warn(
Drop-in regression test (red at b0a7c519, green with the fix)
it('drops the whole queue when the drain bails, preserving FIFO integrity', async () => {
  const onToast = vi.fn();
  const { rerender } = renderApp({ onToast });
  await flush();

  let resolveA!: () => void;
  const aDone = new Promise<void>((r) => {
    resolveA = r;
  });
  mockSessionActions.sendShellCommand
    .mockReturnValueOnce(aDone)
    .mockResolvedValue(undefined);

  act(() => {
    testState.streamingState = 'responding';
    rerender({ onToast });
  });
  await act(async () => {
    testState.latestChatEditorProps?.onSubmit('!a');
    testState.latestChatEditorProps?.onSubmit('!b');
    await Promise.resolve();
  });

  act(() => {
    testState.streamingState = 'idle';
    rerender({ onToast });
  });
  await flush();
  expect(mockSessionActions.sendShellCommand).toHaveBeenCalledTimes(1);

  // User queues `c` while `a` is still running.
  act(() => {
    testState.streamingState = 'responding';
    rerender({ onToast });
  });
  await act(async () => {
    testState.latestChatEditorProps?.onSubmit('!c');
    await Promise.resolve();
  });
  act(() => {
    testState.streamingState = 'idle';
    rerender({ onToast });
  });
  await flush();

  mockConnection.status = 'disconnected';
  await act(async () => {
    resolveA();
    await Promise.resolve();
  });
  await flush();

  // Both `b` and `c` are dropped, and the user is told about both.
  expect(onToast).toHaveBeenCalledWith(
    'warning',
    '2 queued shell commands will not run.',
  );

  // Reconnecting must not resurrect `c` behind the already-dropped `b`.
  mockConnection.status = 'connected';
  act(() => {
    testState.streamingState = 'responding';
    rerender({ onToast });
  });
  act(() => {
    testState.streamingState = 'idle';
    rerender({ onToast });
  });
  await flush();
  await flush();

  expect(mockSessionActions.sendShellCommand).toHaveBeenCalledTimes(1);
  expect(mockSessionActions.sendShellCommand).toHaveBeenCalledWith('a');
});

🟠 b0a7c519's own guard is load-bearing, but its test cannot detect it

Removing shellSubmitInFlightRef.current = false; from handleCancel (App.tsx:6097) — i.e. reverting the newest commit entirely — survives all 194 tests, including the one added for it.

Why the test can't reach it (App.test.tsx:1930, allows new shell commands after cancel during session creation): it calls resolveCreate() before the retry, so by then (a) the submit's .finally() has already cleared the flag and (b) mockConnection.sessionId is set, making needsSession false for the retry — and the guard lives inside if (needsSession). It is unreachable in that ordering.

The line does matter. A/B with creation still in flight at retry time:

retry submit sendShellCommand calls
at head false [["c"]]
line reverted false [] — retry silently swallowed

The user-visible bug it prevents: slow/hung session creation in a new task → user presses Stop → retries !cmd → without the reset, the retry is dropped with no feedback and stays dropped even once creation lands.

Drop-in regression test (red with the line reverted, green at head)
it('runs a retry submitted while session creation is still in flight after cancel', async () => {
  mockConnection.sessionId = undefined;
  let release!: () => void;
  const gate = new Promise<void>((r) => {
    release = r;
  });
  mockSessionActions.createSession.mockImplementation(() =>
    gate.then(() => {
      mockConnection.sessionId = 'session-1';
      return { sessionId: 'session-1' };
    }),
  );
  renderApp({});
  await flush();

  await act(async () => {
    testState.latestChatEditorProps?.onSubmit('!a');
    await Promise.resolve();
  });
  await act(async () => {
    testState.latestChatEditorProps?.onCancel?.();
    await Promise.resolve();
  });
  // Retry while creation is STILL in flight — the only state in which
  // shellSubmitInFlightRef stays true unless handleCancel resets it.
  await act(async () => {
    testState.latestChatEditorProps?.onSubmit('!c');
    await Promise.resolve();
  });
  await act(async () => {
    release();
    await Promise.resolve();
  });
  await flush();

  expect(mockSessionActions.sendShellCommand).toHaveBeenCalledWith('c');
});

🟡 Three more untested guards (surviving mutants)

Mutation Verdict
App.tsx:3957 — make the finally lock release unconditional Survives. The guard is real: it stops an old drain's finally from unblocking a newer drain that took the lock after a cancel/switch. Worth a test or a comment saying it is deliberately defensive.
App.tsx:3933connectionRef.current.sessionId !== drainSessionIdfalse Survives, including aborts remaining commands if the session changes mid-drain (App.test.tsx:1394). That test actually passes via the generation check, since the session-switch effect bumps it. The check looks redundant with the generation bump; either delete it or name the test for what it verifies.
App.tsx:3915 — drop the prev === 'idle' half of the transition guard Survives. Plausibly redundant now that isDrainingRef + the empty-queue check exist. Either way it is unverified complexity.

🟡 Carried over from R4 — still open

  • Stop cannot abort a running shell command. cancel() aborts activePromptsRef.get(session.sessionId) (packages/webui/src/daemon/session/actions.ts:465), but sendShellCommand registers its controller under `${sessionId}:shell` (actions.ts:1103-1106). Outside this diff, but this PR makes it far more reachable: Stop during a drain clears the queue yet leaves the in-flight command running.
  • Queued shell commands have no UI and the queue is unbounded. Regular queued prompts render in QueuedPromptDisplay; a queued ! command produces one toast and then becomes invisible — not listable, editable, or individually removable, with no cap on how many can pile up during a long turn. Given these are shell commands, "I can't see what I queued and can only discard all of it" is a sharper gap than it is for prose prompts.

Minor

  • On a session switch mid-drain both the effect (3902) and the drain (3941) can toast, giving the user two "N queued shell commands will not run." toasts with different counts.
  • The PR body's "Environment" line still says 173 tests; the suite is at 194.

Verdict: the headline feature and the R6 blocker are in good shape, and the mutation matrix is much stronger than in earlier rounds. Requesting changes for the disconnect drop-path leak, and asking for the handleCancel coverage gap to be closed since that commit is currently protected by a test that cannot fail.

中文说明

结论

R7 @ b0a7c519 方法:PR head 独立 worktree,全量 client/App.test.tsx(194/194 通过),对所有新增 guard 做 15 个变异体矩阵 + 定向 A/B 探针。15 个变异体杀掉 11 个。

✅ R6 的 blocker 已修复,且覆盖真实有效

c51919bApp.tsx:3897 加入懒创建豁免。我重跑了 R6 变异体(把条件改成 if (true))——被杀掉,杀它的是 runs the ! command in a new task even when the session-id render commits before attach resolves。该测试跨越了 act() 边界,因此不再对 commit/passive-effect 时序失明。blocker 确实以正确的理由关闭。

FIFO 顺序、drain 中途重读队列、重复提交守卫、cancel/切换会话时的清空、return !needsSession 均有变异覆盖;i18n 的 EN/ZH 两侧都有新 key,无残留 queue.shellBlocked

🔴 Blocker:断连退出路径会泄漏队列并破坏 FIFO

App.tsx:3931-3943。drain 有三种退出原因:其中两种(会话切换效果、handleCancel 引起的 generation bump)会在 bump 处清空 queuedShellCommandsRef;第三种 status !== 'connected'3934)不会。于是被 f182512 的批次重读(3950)捡起来的、drain 中途排入的命令会存活下来。

实测复现(探针输出,非推测):回合中排入 ab → drain 执行 aa 运行期间用户又排入 c → 断连 → a 完成。

断开后调用 = [["a"]]
toast      = [["warning","1 queued shell command will not run."]]
--- 重连 + 一次 非 idle→idle 转换 ---
最终调用   = [["a"],["c"]]

用户排的是 a, b, ca 执行了,b 被丢弃并提示"不会执行",而 c 之后却执行了 —— 较早的命令被丢、较晚的反而跑了,顺序错乱,且跨越了一次断连。c 也从未计入丢弃提示,导致上报数量偏少。

修复(已验证:修复前红 / 修复后绿,加上新测试后全量 195/195):

-              const dropped = batch.length - i;
+              const dropped =
+                batch.length - i + queuedShellCommandsRef.current.length;
+              queuedShellCommandsRef.current = [];

回归测试见上方英文折叠块。

🟠 最新 commit 的守卫确实有用,但其测试无法发现它

删掉 handleCancel 中的 shellSubmitInFlightRef.current = false;App.tsx:6097,即完整回退最新 commit)——194 个测试全绿,包括为它新增的那个。

原因(App.test.tsx:1930):测试在重试之前就调用了 resolveCreate(),此时 (a) 提交的 .finally() 已清掉标志,(b) mockConnection.sessionId 已设置使重试的 needsSession 为 false —— 而守卫位于 if (needsSession) 内部,该路径根本到不了。

这一行确实有用。重试时创建仍在进行中的 A/B:

重试返回 sendShellCommand 调用
head false [["c"]]
回退该行 false [] —— 重试被静默吞掉

对应的用户可见问题:新任务中会话创建缓慢/卡住 → 用户按 Stop → 重试 !cmd → 若无该重置,重试被无提示丢弃,且即使创建最终成功也不会执行。回归测试见上方英文折叠块。

🟡 另外三个未被测试覆盖的守卫(存活变异体)

  • App.tsx:3957finally 的锁释放改为无条件 —— 存活。该守卫是真实需要的:防止旧 drain 的 finally 解锁在 cancel/切换后接手的新 drain。建议补测试或加注释说明这是刻意的防御。
  • App.tsx:3933sessionId !== drainSessionId 改为 false —— 存活,连名为 aborts remaining commands if the session changes mid-drainApp.test.tsx:1394)的测试也杀不掉它:该测试其实是靠 generation 检查通过的。此检查与 generation bump 重复,建议删除或改测试名。
  • App.tsx:3915 去掉 prev === 'idle' —— 存活。在已有 isDrainingRef 和空队列检查后可能已冗余,无论如何目前未被验证。

🟡 R4 遗留、仍未解决

  • Stop 无法中止正在运行的 shell 命令。 cancel() 中止的是 activePromptsRef.get(session.sessionId)packages/webui/src/daemon/session/actions.ts:465),而 sendShellCommand 把 controller 注册在 `${sessionId}:shell`actions.ts:1103-1106)。虽在本 diff 之外,但本 PR 让它更容易被触发:drain 期间按 Stop 会清空队列,正在执行的那条却继续跑。
  • 排队的 shell 命令没有 UI,队列无上限。 普通排队消息有 QueuedPromptDisplay;排队的 ! 命令只有一个 toast,之后完全不可见——无法查看、编辑或单条删除,长回合中也没有数量上限。考虑到这些是 shell 命令,"看不见排了什么、只能全部丢弃"比对普通消息要严重得多。

次要

  • 会话切换发生在 drain 中途时,效果(3902)与 drain(3941)可能各弹一次 toast,给出两条数字不同的"N queued shell commands will not run."。
  • PR 描述的 Environment 仍写 173 个测试,实际已 194。

总体: 主体功能与 R6 blocker 状态良好,变异矩阵也比前几轮扎实得多。因断连退出路径的队列泄漏请求变更,并希望补上 handleCancel 的覆盖缺口——该 commit 目前由一个不可能失败的测试"保护"。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review round — R7 feedback addressed

Addressed the two change-requested items from the R7 review; deferred the
suggestion-level items per the round-balloon rule (this PR is past ~5 review
rounds, so only correctness fixes land here and the rest ride into a follow-up).

🔴 Blocker — disconnect drop-path leaked the queue and broke FIFO — FIXED

The drain bails for three reasons. Two of them (a generation bump from the
session-switch effect or from handleCancel) wipe queuedShellCommandsRef at
the bump site, but the third — connectionRef.current.status !== 'connected'
did not. Anything queued mid-drain and parked in queuedShellCommandsRef
waiting for the next batch re-read survived the bail, so on reconnect a newer
command (c) could run after an older one (b) had been reported dropped.

Fix in App.tsx: the bail path now counts the still-parked commands in the
dropped total and clears the queue so a reconnect cannot resurrect them.

-              const dropped = batch.length - i;
+              const dropped =
+                batch.length - i + queuedShellCommandsRef.current.length;
+              queuedShellCommandsRef.current = [];

Added the regression test drops the whole queue when the drain bails, preserving FIFO integrity. Confirmed it is load-bearing: red with the fix
reverted, green with it.

🟠 handleCancel guard was load-bearing but its test could not fail — CLOSED

The newest commit's shellSubmitInFlightRef.current = false; reset in
handleCancel was covered only by a test that resolves session creation before
the retry, so the retry never reaches the if (needsSession) branch where the
guard lives — reverting the line kept all 194 tests green.

Added runs a retry submitted while session creation is still in flight after cancel, which retries while creation is still pending — the only state in
which the flag stays true unless handleCancel resets it. Confirmed
load-bearing: red with the reset line reverted, green at head.

🟡 Deferred to a follow-up (suggestions, not correctness fixes)

  • Three surviving-mutant guards (App.tsx finally-lock release, the
    sessionId !== drainSessionId check, and the prev === 'idle' transition
    half): each may be redundant with a newer guard; deciding delete-vs-test per
    guard is judgment work that would grow scope at R7. Recorded here so nothing
    is silently dropped.
  • R4 carryovers — Stop cannot abort a running shell command (the
    controller is registered under ${sessionId}:shell, outside this diff), and
    queued shell commands have no UI and an unbounded queue. Both are feature
    gaps / product decisions beyond this bugfix's scope.
  • Minor — a session switch mid-drain can emit two drop toasts with
    different counts (cosmetic). The PR body's "Environment" test count is stale
    (says 173; the suite is now 196) — the PR body is workflow-owned and not
    editable from here.

Conflict notes

--conflict false; no merge performed.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx vitest run client/App.test.tsx (packages/web-shell, touched) — 196 passed (194 prior + 2 new)
  • Blocker regression test confirmed red with the fix reverted, green with it
  • handleCancel regression test confirmed red with the reset line reverted, green at head
中文说明

Autofix 审查轮次 — 已处理 R7 反馈

处理了 R7 审查中两项请求变更(change-requested)的问题;建议类(suggestion)条目按"审查轮次膨胀"规则延后处理(本 PR 已超过约 5 轮审查,因此此处只落地正确性修复,其余转入后续 issue/PR)。

🔴 Blocker — 断连退出路径泄漏队列并破坏 FIFO — 已修复

drain 有三种退出原因。其中两种(会话切换效果或 handleCancel 引起的 generation bump)会在 bump 处清空 queuedShellCommandsRef;但第三种 —— connectionRef.current.status !== 'connected' —— 不会。任何在 drain 中途排入、暂存在 queuedShellCommandsRef 中等待下一批次重读的命令都会在退出后存活,于是重连后较晚的命令(c)可能在较早的命令(b)已被报告丢弃之后仍然执行。

App.tsx 中的修复:退出路径现在把仍暂存的命令计入丢弃总数,并清空队列,使重连无法把它们复活。

-              const dropped = batch.length - i;
+              const dropped =
+                batch.length - i + queuedShellCommandsRef.current.length;
+              queuedShellCommandsRef.current = [];

新增回归测试 drops the whole queue when the drain bails, preserving FIFO integrity。已确认其确实有效:回退修复时为红,应用修复后为绿。

🟠 handleCancel 守卫确实有用,但其测试不可能失败 — 已补齐

最新 commit 在 handleCancel 中的 shellSubmitInFlightRef.current = false; 重置,原本只被一个在重试之前就完成会话创建的测试覆盖,因此重试永远到不了守卫所在的 if (needsSession) 分支 —— 回退该行后 194 个测试仍全绿。

新增 runs a retry submitted while session creation is still in flight after cancel,在创建仍在进行时重试 —— 这是除非 handleCancel 重置该标志、否则标志会保持为 true 的唯一状态。已确认有效:回退该重置行时为红,head 上为绿。

🟡 延后至后续处理(建议类,非正确性修复)

  • 三个存活变异体守卫App.tsx 的 finally 锁释放、sessionId !== drainSessionId 检查、以及 prev === 'idle' 转换条件的一半):每一个都可能与更新的守卫冗余;逐个判断"删除还是补测试"属于会在 R7 扩大范围的判断性工作。在此记录以免被静默丢弃。
  • R4 遗留 —— Stop 无法中止正在运行的 shell 命令(controller 注册在 ${sessionId}:shell 下,位于本 diff 之外);排队的 shell 命令没有 UI 且队列无上限。两者都是超出本 bugfix 范围的功能缺口/产品决策。
  • 次要 —— 会话切换发生在 drain 中途时可能弹出两条数字不同的丢弃 toast(外观问题)。PR 描述的 "Environment" 测试数已过期(写 173;实际现为 196)—— PR 描述由工作流维护,此处无法编辑。

冲突说明

--conflict false;未执行合并。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • npx vitest run client/App.test.tsx(packages/web-shell,受影响)— 196 通过(原 194 + 新增 2)
  • Blocker 回归测试已确认:回退修复时为红,应用后为绿
  • handleCancel 回归测试已确认:回退重置行时为红,head 上为绿

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

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


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Not reviewed: Agent 0: Issue fidelity & root-cause ownership, Agent 4: Performance & efficiency, Agent 7: Build & test verification — its prompt was built, but no agent on record was launched with it.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Review — R8 @ 7695a1a6

Scope: packages/web-shell/client/{App.tsx,App.test.tsx,i18n.tsx} (+1184/−23). Delta since R7 is just 7695a1a6 (App.tsx +4/−1, App.test.tsx +113).
Method: isolated worktree at the PR head; full client/App.test.tsx (196/196 green); a mutation matrix over the R8 and b0a7c519 changes; two new A/B probes.

✅ Both R7 findings are closed — and closed for the right reason

Blocker (disconnect drop-path leaked the queue) — fixed. App.tsx:3936-3938 now folds the still-parked commands into dropped and clears the queue. Mutant M1 (revert the hunk) is killed, by exactly one test — the new drops the whole queue when the drain bails, preserving FIFO integrity. Two finer mutants confirm both halves are load-bearing: M3 (keep the count, drop the wipe) and M4 (keep the wipe, drop the count) are both killed.

Vacuous-test finding — closed. In R7, b0a7c519's shellSubmitInFlightRef.current = false in handleCancel survived its own test (allows new shell commands after cancel during session creation resolves creation before the retry, so the guard is unreachable there). The new runs a retry submitted while session creation is still in flight after cancel targets exactly that state: mutant M2 (delete the line) is now killed.


🔴 Blocker — the R8 wipe is applied to all three bail reasons, but two of them already wiped

App.tsx:3931-3945. The drain bails for three reasons. Two of them — a generation bump from the session-switch effect (3892-3893) or from handleCancel (6095-6096) — already wipe queuedShellCommandsRef at the bump site. Only status !== 'connected' has no wipe of its own; that was the R7 leak.

The new unconditional queuedShellCommandsRef.current = [] therefore also eats anything queued after the drop event — which, for the two generation-bump reasons, is fresh user intent rather than stale batch residue.

The window is real because Stop does not abort a running shell command (see the first suggestion below). While the drain's in-flight command keeps running, the composer stays blocked, so the user's next ! command queues — and is then destroyed when that command finally resolves and the stale drain wakes up to bail.

Probe 1 (cancel): queue !a,!b → idle → drain dispatches a (pending) → press Stop → submit !x while a is still running → resolve a → idle.

dispatched toast
head 7695a1a6 [["a"]]x silently lost 2 queued shell commands will not run.
only the R8 hunk reverted [["a"],["x"]] 1 queued shell command will not run.

Nothing but the R8 hunk differs between those two rows, so this is a regression introduced by this commit, not a pre-existing gap. The count is wrong too: the user is told 2 commands were dropped, and one of them is the command they submitted after pressing Stop.

Probe 2 (session switch) reproduces identically and is worse — !x is queued against session-2 and is silently discarded by session-1's stale drain. Head [["a"]], reverted [["a"],["x"]].

Fix — scope the wipe to the one reason that needs it:

+            const generationChanged =
+              drainGenerationRef.current !== generation;
             if (
-              drainGenerationRef.current !== generation ||
+              generationChanged ||
               connectionRef.current.sessionId !== drainSessionId ||
               connectionRef.current.status !== 'connected'
             ) {
-              const dropped =
-                batch.length - i + queuedShellCommandsRef.current.length;
-              queuedShellCommandsRef.current = [];
+              let dropped = batch.length - i;
+              if (!generationChanged) {
+                // The two generation-bump sites (session switch, cancel) wipe
+                // the queue themselves, so anything parked here was queued
+                // after the drop and is fresh user intent. A disconnect has no
+                // such wipe — clear it so a reconnect cannot resurrect a newer
+                // command behind an already-dropped older one.
+                dropped += queuedShellCommandsRef.current.length;
+                queuedShellCommandsRef.current = [];
+              }

Verified with this patch applied: the R7 regression test drops the whole queue when the drain bails, preserving FIFO integrity still passes, both probes pass, and the suite is 198/198 (196 + 2 probes). eslint client/App.tsx clean.

Please land both probes as regression tests — without them nothing in the suite distinguishes "wipe on disconnect" from "wipe on every bail", which is exactly how this regression got in.


💡 Suggestions (not blocking)

  • Stop cannot abort a running shell command. cancel() reads activePromptsRef.current.get(session.sessionId) (packages/webui/src/daemon/session/actions.ts:465), but sendShellCommand registers its controller under `${session.sessionId}:shell` (actions.ts:1103-1106), so the lookup misses. Pre-existing and daemon-side, so out of this PR's scope — but this PR makes it far more reachable (the drain now fires shell commands automatically right after a turn), and it is precisely what holds the blocker's window open. Worth a follow-up.
  • Queued ! commands are invisible. Queued prompts get QueuedPromptDisplay; queued shell commands get one info toast and then nothing — no list, no way to remove one, and the queue is unbounded. The toast also fires once per command, so queueing three yields three toasts.
  • Double drop-toast. On a session switch or cancel mid-drain, the wipe site pushes queue.shellDropped and then the drain bail pushes a second one. Cosmetic, but two warnings for one event reads as a bug.
  • Clean elsewhere: queue.shellDropped is correctly pluralised in EN, present in both EN and ZH, and there is no leftover queue.shellBlocked. The scheduleDelayedSessionListReload extraction (App.tsx:3157-3164) is a behaviour-preserving refactor that correctly threads the new dep.

Verdict: request changes — one blocker (regression introduced by 7695a1a6), with a validated one-hunk fix and two red/green probes above.

中文说明

评审 — R8 @ 7695a1a6

范围: packages/web-shell/client/{App.tsx,App.test.tsx,i18n.tsx}(+1184/−23)。相对 R7 的增量仅为 7695a1a6(App.tsx +4/−1,App.test.tsx +113)。
方法: 在 PR HEAD 的独立 worktree 中运行完整 client/App.test.tsx196/196 通过);对 R8 与 b0a7c519 的改动做变异矩阵;新增两个 A/B 探针。

✅ R7 的两项发现均已关闭,且关闭原因正确

阻塞项(断连丢弃路径泄漏队列)已修复。 App.tsx:3936-3938 现在把仍驻留队列中的命令计入 dropped 并清空队列。变异 M1(回退该 hunk)被杀死,且恰好只由新增测试 drops the whole queue when the drain bails, preserving FIFO integrity 捕获。两个更细的变异确认两半都是承重的:M3(保留计数、去掉清空)与 M4(保留清空、去掉计数)均被杀死。

空测试问题已关闭。 R7 中 b0a7c519handleCancel 加的 shellSubmitInFlightRef.current = false 能在自己的测试下存活(allows new shell commands after cancel during session creation 在重试之前就 resolve 了 session 创建,该保护分支不可达)。新增的 runs a retry submitted while session creation is still in flight after cancel 正好命中该状态:变异 M2(删除该行)现在被杀死


🔴 阻塞项 —— R8 的清空作用于全部三种 bail 原因,但其中两种已经清空过了

App.tsx:3931-3945。drain 有三种 bail 原因。其中两种——来自 session 切换 effect(3892-3893)或 handleCancel6095-6096)的 generation 自增——在自增处已经清空过 queuedShellCommandsRef。只有 status !== 'connected' 没有自己的清空动作,那正是 R7 的泄漏点。

因此,新加的无条件 queuedShellCommandsRef.current = [] 会连丢弃事件之后入队的命令一并吃掉——对那两种 generation 自增原因而言,那是用户新的意图,而不是旧批次的残留。

这个窗口是真实存在的,因为 Stop 无法中止正在运行的 shell 命令(见下方第一条建议)。drain 手上那条命令还在跑的时候,输入框仍处于阻塞态,用户下一条 ! 命令就会入队——然后在那条命令终于 resolve、陈旧的 drain 醒来 bail 时被销毁。

探针 1(取消): 入队 !a!b → idle → drain 派发 a(挂起)→ 按 Stopa 仍在运行时提交 !x → resolve a → idle。

实际派发 toast
HEAD 7695a1a6 [["a"]] —— x 被静默丢弃 2 queued shell commands will not run.
仅回退 R8 hunk [["a"],["x"]] 1 queued shell command will not run.

两行之间只有 R8 hunk 不同,所以这是本次提交引入的回归,而非既有缺口。计数也是错的:用户被告知丢了 2 条,其中一条恰恰是他们按下 Stop 之后才提交的。

探针 2(切换 session) 表现完全一致,而且更糟——!x 是针对 session-2 入队的,却被 session-1 的陈旧 drain 静默丢弃。HEAD [["a"]],回退后 [["a"],["x"]]

修复 —— 把清空收窄到真正需要它的那一种原因(补丁见上方英文部分的 diff)。

已验证:打上该补丁后,R7 的回归测试 drops the whole queue when the drain bails, preserving FIFO integrity 仍然通过,两个探针均通过,整套 198/198(196 + 2 个探针),eslint client/App.tsx 无告警。

建议把两个探针作为回归测试一并合入——否则测试套件里没有任何东西能区分"仅断连时清空"和"每次 bail 都清空",这个回归正是这样溜进来的。


💡 建议(非阻塞)

  • Stop 无法中止正在运行的 shell 命令。 cancel() 读的是 activePromptsRef.current.get(session.sessionId)packages/webui/src/daemon/session/actions.ts:465),而 sendShellCommand 把 controller 注册在 `${session.sessionId}:shell` 下(actions.ts:1103-1106),查找必然落空。这是既有的 daemon 侧问题,不属于本 PR 范围——但本 PR 大幅提高了它的可达性(drain 现在会在回合结束后自动发起 shell 命令),而且它正是让上述阻塞项窗口一直敞开的原因。值得开一个后续 PR。
  • 排队的 ! 命令不可见。 排队的 prompt 有 QueuedPromptDisplay;排队的 shell 命令只有一个 info toast,之后就没了——没有列表、无法移除某一条,队列也没有上限。而且 toast 是每条命令弹一次,排三条就弹三次。
  • 重复的丢弃 toast。 在 drain 进行中切换 session 或取消时,清空点会推一次 queue.shellDropped,随后 drain bail 又推一次。虽属观感问题,但一个事件弹两条警告看起来像 bug。
  • 其余部分干净: queue.shellDropped 的英文单复数处理正确,EN/ZH 均已补齐,没有遗留的 queue.shellBlockedscheduleDelayedSessionListReload 的抽取(App.tsx:3157-3164)是保持行为不变的重构,并正确补上了新的依赖项。

结论:请求修改 —— 一个阻塞项(由 7695a1a6 引入的回归),上文已给出经过验证的单 hunk 修复与两个 red/green 探针。

…M#7724)

The unconditional queue wipe in the drain bail path destroyed commands
queued after a cancel or session switch, because those generation-bump
sites already wipe the queue themselves. Only the disconnect case lacks
its own wipe, so scope the drain-bail wipe to that case alone.

Adds two regression probes: commands queued after cancel and after a
session switch while a drain command is still in flight must survive
the stale drain's bail and be dispatched by the next drain.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

R8 Review — Address Summary

🔴 Blocker — unconditional queue wipe destroys post-drop commands (Fixed)

Finding: The R8 commit 7695a1a6 added an unconditional queuedShellCommandsRef.current = [] in the drain bail path, but two of the three bail reasons (generation bump from session switch and from handleCancel) already wipe the queue at the bump site. The unconditional wipe therefore destroyed commands queued after the drop event — fresh user intent rather than stale batch residue.

Decision: Required — fixed.

Change: Scoped the queue wipe to the one bail reason that needs it (status !== 'connected'). When the generation changed (session switch or cancel), the drain bail no longer touches the queue, because those sites already cleared it and anything parked since is new user intent. When the generation did not change (disconnect), the wipe still applies so a reconnect cannot resurrect a newer command behind an already-dropped older one.

Added two regression probes requested by the reviewer:

  • preserves commands queued after cancel while a drain command is still running — queues !x after Stop while a is in flight; verifies x is dispatched after a resolves.
  • preserves commands queued after a session switch while a drain command is still running — queues !x after switching to session-2 while a is in flight; verifies x is dispatched after a resolves.

💡 Suggestions (Declined)

  1. Stop cannot abort a running shell command — Declined: daemon-side issue (cancel() looks up sessionId but sendShellCommand registers under sessionId:shell), pre-existing, and out of this PR's scope. Agreed it is worth a follow-up.

  2. Queued ! commands are invisible — Declined: feature request (queue list UI, per-command removal, unbounded queue, per-command toasts). Out of scope for this bugfix PR.

  3. Double drop-toast on session switch/cancel mid-drain — Declined: cosmetic. The scoped wipe partially mitigates this (the drain bail's toast now counts only the in-flight batch remainder, not the already-wiped queue), but fully deduplicating would require coordinating toast state across the wipe site and the drain bail, which is not worth the diff growth here.

Conflict notes

No conflicts (--conflict false).

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx vitest run client/App.test.tsx (packages/web-shell) — 198 passed (196 existing + 2 new probes)
中文说明

R8 评审 — 处理总结

🔴 阻塞项 — 无条件清空队列销毁了丢弃事件之后入队的命令(已修复)

发现: R8 提交 7695a1a6 在 drain bail 路径中添加了无条件的 queuedShellCommandsRef.current = [],但三种 bail 原因中的两种(来自 session 切换和 handleCancel 的 generation 自增)在自增处已经清空过队列。因此无条件清空会销毁丢弃事件之后入队的命令——那是用户新的意图,而非旧批次的残留。

决定: 必需项 — 已修复。

改动: 将队列清空收窄到真正需要它的那一种 bail 原因(status !== 'connected')。当 generation 发生变化(session 切换或取消)时,drain bail 不再触碰队列,因为这些站点已经清空过队列,之后入队的都是新的用户意图。当 generation 未变化(断连)时,清空仍然生效,以防重连后在已丢弃的旧命令后面复活新命令。

新增了评审者要求的两个回归探针:

  • preserves commands queued after cancel while a drain command is still running — 在 a 仍在运行时按 Stop 后入队 !x;验证 a resolve 后 x 被派发。
  • preserves commands queued after a session switch while a drain command is still running — 在 a 仍在运行时切换到 session-2 后入队 !x;验证 a resolve 后 x 被派发。

💡 建议(已拒绝)

  1. Stop 无法中止正在运行的 shell 命令 — 拒绝:daemon 侧问题(cancel() 查找 sessionId,但 sendShellCommand 注册在 sessionId:shell 下),属于既有问题,不在本 PR 范围内。同意值得开后续 PR。

  2. 排队的 ! 命令不可见 — 拒绝:功能请求(队列列表 UI、逐条移除、无界队列、逐条 toast)。不在本 bugfix PR 范围内。

  3. session 切换/取消时重复的丢弃 toast — 拒绝:观感问题。收窄后的清空部分缓解了此问题(drain bail 的 toast 现在只计入当前批次的剩余量,不含已清空的队列),但完全去重需要在清空点和 drain bail 之间协调 toast 状态,不值得增加 diff 体量。

冲突说明

无冲突(--conflict false)。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • npx vitest run client/App.test.tsx(packages/web-shell)— 198 通过(196 个既有 + 2 个新探针)

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

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


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

Comment thread packages/web-shell/client/App.tsx Outdated
Comment thread packages/web-shell/client/App.test.tsx
Comment thread packages/web-shell/client/App.tsx
Comment on lines +3932 to +3936
if (
generationChanged ||
connectionRef.current.sessionId !== drainSessionId ||
connectionRef.current.status !== 'connected'
) {

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.

[Suggestion] If sendShellCommand returns a promise that never settles (daemon process crashes mid-command, WebSocket stalls without tearing down), the drain loop is permanently stuck — isDrainingRef stays true, no new drain can start, and all subsequently queued shell commands accumulate indefinitely with no toast or log. — Concrete cost: user queues commands during a turn, daemon hangs, commands are silently lost with no operator-visible signal.

Consider adding a per-command timeout (e.g. Promise.race with a 30-second timeout) or resetting isDrainingRef when connectionRef.current.status transitions to disconnected so a reconnect can start a fresh drain.

中文说明

如果 sendShellCommand 返回的 promise 永远不会 settle(daemon 进程崩溃、WebSocket 挂起但未断开),drain 循环将永久卡住——isDrainingRef 保持为 true,新的 drain 无法启动,后续排队的命令将无限积累且无任何提示。建议添加单命令超时机制,或在连接状态变为 disconnected 时重置 isDrainingRef

— qwen3.7-max via Qwen Code /review

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.

Declined — the drain loop already checks connectionRef.current.status !== 'connected' between commands, which handles disconnection. A permanently unsettled sendShellCommand promise indicates a transport/daemon bug that should be fixed at that layer, not papered over with a UI-level timeout. A per-command timeout would also create false positives on legitimately slow commands (large builds, long-running scripts).

中文说明

拒绝 — drain 循环已在每条命令之间检查 connectionRef.current.status !== 'connected',可以处理断开连接的情况。sendShellCommand 返回永不 settle 的 promise 表明是传输层/daemon 的 bug,应在该层修复,而非用 UI 层超时来掩盖。单命令超时还会对合法慢命令(大型构建、长时间运行的脚本)产生误报。

QwenLM#7724)

connectionRef.current.sessionId is only updated at render time, but the
.then() microtask after ensureSessionForPrompt() runs before React
commits the setConnection state update — so the post-creation cleanup
(editor clear, session-change dispatch, sidebar reload) never executed
for lazily-created sessions.

Have ensureSessionForPrompt() return the allocated session ID and use
it directly in the submit handler. Also include the command text in
drain-loop error toasts and assert the queue-path return value in the
queueing test.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Review feedback addressed

[Critical] Stale connectionRef.current.sessionId in .then() microtask (rc:3654153953)

Decision: Fixed (with a different approach than suggested)

The finding is valid: connectionRef.current is only updated at render time (line 3009), but the .then() microtask runs before React commits the setConnection state update from createSession. The post-creation cleanup block (editor clear, dispatchSessionChange, scheduleDelayedSessionListReload) never executed for lazily-created sessions.

However, the suggested fix (capture preparingSessionIdRef.current in .finally()) is flawed: clearPreparation is registered on the same promise before the caller's .finally(), so it nulls preparingSessionIdRef.current before .finally() runs.

Fix applied: ensureSessionForPrompt() now returns the allocated session ID (Promise<string | undefined>). The session ID is captured in a local variable inside the async IIFE via onSessionAllocated and returned. The submit handler receives it as the .then() parameter and uses it directly, bypassing both the stale connectionRef and the already-cleared preparingSessionIdRef.

[Suggestion] Missing return-value assertion in queueing test (rc:3654153955)

Decision: Implemented

Added let accepted: boolean | void capture and expect(accepted).toBe(true) assertion, matching the pattern used by the other two ! path tests (lazy creation and existing session).

[Suggestion] Include command text in drain-loop error toast (rc:3654153959)

Decision: Implemented

Changed the drain-loop catch from 'Failed to execute shell command' to `Failed to execute shell command: !${batch[i]}` so users can identify which command failed in a multi-command batch.

[Suggestion] Per-command timeout for hung drain loop (rc:3654153962)

Decision: Declined

The drain loop already checks connectionRef.current.status !== 'connected' between commands, which handles disconnection. A permanently unsettled promise from sendShellCommand indicates a transport/daemon bug that should be fixed at that layer, not papered over with a UI-level timeout. Adding a timeout creates a new failure mode: false-positive timeouts on legitimately slow commands (large builds, long-running scripts). Not worth the diff growth.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx vitest run client/App.test.tsx (packages/web-shell) — 198 passed
中文说明

已处理的审查反馈

[Critical] .then() 微任务中 connectionRef.current.sessionId 过期 (rc:3654153953)

决定:已修复(采用了与建议不同的方案)

该发现有效:connectionRef.current 仅在渲染时更新(第 3009 行),但 .then() 微任务在 React 提交 createSessionsetConnection 状态更新之前运行。因此,懒创建 session 后的清理代码块(编辑器清空、dispatchSessionChangescheduleDelayedSessionListReload)从未执行。

但建议的修复方案(在 .finally() 中捕获 preparingSessionIdRef.current)存在缺陷:clearPreparation 在同一个 promise 上注册的时间早于调用者的 .finally(),因此它会在 .finally() 运行之前将 preparingSessionIdRef.current 置为 null

实际修复: ensureSessionForPrompt() 现在返回已分配的 session ID(Promise<string | undefined>)。session ID 通过 onSessionAllocated 在异步 IIFE 内部的局部变量中捕获并返回。提交处理器作为 .then() 参数接收它并直接使用,绕过了过期的 connectionRef 和已清除的 preparingSessionIdRef

[Suggestion] 排队测试缺少返回值断言 (rc:3654153955)

决定:已实现

添加了 let accepted: boolean | void 捕获和 expect(accepted).toBe(true) 断言,与其他两条 ! 路径测试(懒创建和已有 session)的模式保持一致。

[Suggestion] drain 循环错误提示中包含命令文本 (rc:3654153959)

决定:已实现

将 drain 循环的 catch 从 'Failed to execute shell command' 改为 `Failed to execute shell command: !${batch[i]}`,以便用户在多命令批次中识别哪条命令失败。

[Suggestion] 为挂起的 drain 循环添加单命令超时 (rc:3654153962)

决定:拒绝

drain 循环已在每条命令之间检查 connectionRef.current.status !== 'connected',可以处理断开连接的情况。sendShellCommand 返回永不 settle 的 promise 表明是传输层/daemon 的 bug,应在该层修复,而非用 UI 层超时来掩盖。添加超时会引入新的故障模式:对合法慢命令(大型构建、长时间运行的脚本)产生误报超时。不值得增加 diff 体量。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • npx vitest run client/App.test.tsx(packages/web-shell)— 198 个测试通过

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

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


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

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

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

— qwen3.7-max via Qwen Code /review

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

中文说明

已审查。 建议见行内评论。 1 条建议级发现无法锚定到改动行,已丢弃;此处无需进一步处理。

— qwen3.7-max via Qwen Code /review

Comment thread packages/web-shell/client/App.test.tsx
Comment thread packages/web-shell/client/App.tsx
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Review feedback addressed

[rc:3654508028] Suggestion: test sendShellCommand rejection in the immediate path

Decision: Implemented.

Added test reports an error when sendShellCommand rejects with an existing session — mocks sendShellCommand to reject, submits !ls with an active session, and asserts reportError fires (via onToast and console.error) and the submit returns true (editor remains functional).

[rc:3654508038] Suggestion: test bare ! and whitespace-only ! guard

Decision: Implemented.

Added test returns false for bare ! or whitespace-only ! commands — submits ! and ! , asserts both return false, and neither sendShellCommand nor createSession is called.

Conflict notes

No conflicts (--conflict false).

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx vitest run client/App.test.tsx (packages/web-shell) — 200 passed
中文说明

已处理的审查反馈

[rc:3654508028] 建议:测试即时路径中 sendShellCommand 拒绝的情况

决定: 已实现。

添加了测试 reports an error when sendShellCommand rejects with an existing session——mock sendShellCommand 为拒绝,在已有活动会话时提交 !ls,断言 reportError 被触发(通过 onToastconsole.error),且提交返回 true(编辑器保持可用)。

[rc:3654508038] 建议:测试裸 ! 和仅空白 ! 的守卫

决定: 已实现。

添加了测试 returns false for bare ! or whitespace-only ! commands——提交 !! ,断言两者都返回 false,且 sendShellCommandcreateSession 均未被调用。

冲突说明

无冲突(--conflict false)。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • npx vitest run client/App.test.tsx(packages/web-shell)— 200 个通过

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

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


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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

中文说明

⚠️ 已从批准降级为评论:CI still running。 已审查。

— qwen3.7-max via Qwen Code /review

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Autofix review round — no action needed

This round had no actionable feedback on PR #7724.

  • Reviews: the only entry is an automated-reviewer notice that its approval was downgraded to a comment because CI was still running at review time. It names no defect, suggestion, or change request. CI has since completed and all checks are green (Test (ubuntu-latest, Node 22.x), web-shell E2E Smoke, review-pr, etc. — SUCCESS or SKIPPED, no failures).
  • Inline comments: none.
  • Issue-level comments: none.
  • Failed / still-red checks: none.

No code change was made and nothing was committed. The branch is unchanged.

中文说明

Autofix 审查轮次 —— 无需处理

本轮针对 PR #7724 没有可处理的反馈。

  • 审查(Reviews): 唯一一条是自动审查器的通知,说明由于审查时 CI 仍在运行,其批准已被降级为评论。该通知未指出任何缺陷、建议或修改请求。此后 CI 已完成,所有检查均为绿色(Test (ubuntu-latest, Node 22.x)web-shell E2E Smokereview-pr 等 —— 均为 SUCCESS 或 SKIPPED,无失败)。
  • 行内评论(Inline comments): 无。
  • Issue 级评论: 无。
  • 失败 / 持续红色的检查: 无。

未做任何代码修改,也未提交任何内容。分支保持不变。

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


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

samuelhsin pushed a commit to samuelhsin/qwen-code that referenced this pull request Jul 27, 2026
…ads the report, no agent (QwenLM#7751)

* feat(review): add script-lint — deterministic linters over a diff's executable scripts

A diff's shell — a `.sh` file, a Makefile recipe, a Dockerfile `RUN`, a GitHub
Actions `run:` block — is code, and its bugs (an unquoted `$x` that word-splits,
a `${PIPESTATUS[1]}` read after the array was reset) are the class a reviewer
misses by reading a long YAML and catches by running the checker. Measured: a
model told in prose to "run the workflow scripts" reads instead (0/4 executed).

So the execution is a command, not a request. `qwen review script-lint` reads the
plan, dispatches shellcheck / actionlint / hadolint by file type over the changed
executable files, filters every finding to whether its line is one the diff
changed (`inDiff`), and reports JSON. A linter that is not installed is disclosed
as skipped, never a clean bill. It is not GitHub-specific — shellcheck applies to
shell wherever it appears; actionlint/hadolint are front-ends for two embeds.

This is the command only; the agent, roster requirement and coverage gate that
make it a non-skippable step follow.

* feat(review): make script-lint a required, coverage-gated review step

The script-lint command exists; nothing ran it. This wires it into the review
the way build-test is wired, so a diff that changes an executable script cannot
be certified without its linters having run.

- A `script-lint` agent role (agent-briefs): reads no diff, runs the command,
  reports from its JSON. `inDiff` findings are the PR's; `skipped` (a linter not
  installed) is disclosed as unreviewed, never clean. Rules are not injected into
  it, same as Build & Test — it reports a tool's verdict, not a read.
- agent-prompt welds the exact `qwen review script-lint --plan/--worktree/--out`
  into that agent's brief with absolute paths, guarding an absent PR number out
  of the --out name — the same treatment, and the same traps avoided, as the
  build-test block it sits beside.
- The roster requires the agent whenever the diff carries a file a linter owns by
  path (a `.sh`/`.bash`, a `.github/workflows/*`, a Dockerfile) and the review has
  a worktree to lint in. Detected by the command's own `pathTool`, so the roster
  and the command cannot disagree about what counts. A pure-TS diff does not
  require it; a diff-only review (no tree) cannot run it, so does not.
- Coverage needs no new code: it derives missing roles from the roster generically
  (`BRIEFS[role].label`), so a required script-lint agent that did not run exit-3s
  check-coverage like any other. The role carries its three labels for that.

End-to-end on a crafted diff (a workflow plus a `deploy.sh` with `rm -rf $TARGET`):
the roster requires script-lint, and the command blocks on the SC2086 on the
changed line while disclosing the workflow as skipped where actionlint is absent.

SKILL.md documents the step; tests cover the roster requirement, the brief weld,
and the subcommand registration.

* fix(review): harden script-lint after review — context lines, fail-closed, symlinks, quoting

Addresses the review findings on this PR:

- inDiff was keyed off the plan's hunk ranges, which include git's three context
  lines, so a pre-existing diagnostic near a real change was marked this PR's and
  could block it. Classify off the diff's added-line ranges (context excluded),
  parsed from the diff; fall back to the plan hunks only when the diff is absent.
- The command wrote the report to --out and printed only "Wrote ...", while the
  agent's brief (and the roster's generated command, which passes --out) says to
  read the JSON it prints. Match build-test: write the file AND always print JSON.
- runTool failed open — every non-ENOENT failure (EACCES, a signal, maxBuffer, an
  unexpected status) fell through as empty stdout and became ok:true. Fail closed:
  such a run is `errored`, which forces ok:false; ENOENT alone stays "not installed".
- The shebang read slurped the whole file and followed symlinks — a changed
  `hang.sh` -> /dev/zero would hang the reviewer. Read only regular files (lstat,
  no follow) and only the first block.
- The welded command interpolated plan/worktree/out as bare words; a worktree path
  with a space would split. Quote them with shellQuotePath.
- Harden the checker environment: shellcheck --norc + drop SHELLCHECK_OPTS, so a
  PR-controlled .shellcheckrc or inherited opts cannot suppress SC2086.
- The roster required the agent for a pure-deletion .sh (a mandatory no-op); gate
  on added lines. Drop the Makefile-recipe claim (no detector backs it). Fix the
  `ok` JSDoc (info blocks too, not just error/warning).
- Tests: inject the tool runner (no binary needed) to cover actionlint/hadolint
  normalisation, the three fail-closed paths, and the context-line classification.

* feat(review): make script-lint a deterministic gate — orchestrator runs it, compose-review is the authority

The review of QwenLM#7749 landed three architectural findings (#10/#11/#12): the
executable-script lint was run by an AGENT, so its execution rested on the model's
honor system, the model decided each finding's severity, and an uninstalled checker
was only disclosed in prose. All three are the exact "a rule a model is asked to
remember will eventually not be remembered" trap the feature exists to close — and a
measurement on PR QwenLM#7724 confirmed it: the strongest model's Step-3 agents, given the
diff and the worktree, missed all three execution-confirmed bugs, one attacker
persona walking into a double-execute and declaring it correct.

So take the model out of the gate entirely:

- The orchestrator runs `qwen review script-lint` as a deterministic step (like
  presubmit) and writes the report next to the plan. No agent.
- `compose-review` derives the report path from the plan and reads it as the SOLE
  authority: a finding on a changed line above `style` is a pre-confirmed `[lint]`
  Critical (needs no verifier — the tool already ran); an uninstalled or crashed
  checker is unreviewed scope that caps a would-be Approve; and — proof it ran — a
  diff that carries an executable script but produced no readable report is itself
  unreviewed (fail closed). A diff-only review, which has no worktree to run it,
  is exempt. Nothing here comes from the input JSON a model wrote.

Removed the `script-lint` agent role (agent-briefs, agent-prompt weld, roster
requirement) and its SKILL.md agent entry; the roster's `hasExecutableScript` is now
exported as the shared predicate the gate reads. The `script-lint` command itself is
unchanged — it was always the deterministic engine; only its trigger and consumer moved.

* fix(review): address the second review pass on script-lint

- parseFindings failed OPEN on non-empty unparseable output (a version skew or a
  deprecation line before the JSON) → `[]` → recorded as a clean `checked` file.
  Return null on that path and push it to `errored`, fail-closed like a bad exit.
- hadolint had no config isolation while shellcheck gets `--norc`; a PR-controlled
  `.hadolint.yaml` could `ignored:` its findings away. Add `--no-config`.
- pathTool recognised only `.sh`/`.bash`, but the shebang regex matches four shells
  — a `.ksh`/`.dash` file never required the lint. Add those extensions so the
  gate's owed-predicate and the command cannot disagree.
- Pin `r.ok` in the not-installed test, and add a mock test for the unparseable path.

* fix(review): address the deterministic-gate re-review — real correctness bugs on the write path

The second review pass on the gate found real bugs, including one this PR
introduced. Fixed:

- hadolint has no `--no-config` flag — a prior round added it, so hadolint exited
  2 (usage error) on EVERY Dockerfile and reported it `errored`. Reverted to the
  plain working invocation; hadolint config isolation needs a verified mechanism
  and is tracked separately.
- actionlint's JSON anchors each diagnostic at the `run:` key line (not the
  changed shell line) and flattens ShellCheck severity, so a style nit read as a
  blocking `error` and a real finding read as pre-existing. Until that source
  mapping is parsed and verified, a workflow is deferred to `skipped` (unreviewed)
  and actionlint is never run — shellcheck still covers standalone shell.
- A recognised path that `firstLineOf` refused (a `hook.sh` symlink, a fifo) was
  silently dropped from the report, so an empty report read as clean. It is now
  recorded as `skipped`. `firstLineOf` distinguishes a true deletion (skip) from
  an irregular file (record).
- The gate's owed-predicate excluded any file with zero added lines, so a
  deletion-only edit that breaks a surviving `.sh` was treated as not-owed. Keyed
  on the post-image (`fileLines`) now: a surviving script is owed, only a true
  deletion is exempt.
- compose-review trusted any body-Critical string containing `[lint]` as
  deterministic — a model-written or injected claim could launder itself past
  verification. Provenance decides now: only `scriptLintGate`'s own findings are
  deterministic (tracked by count); `[lint]` is no longer a trusted tag.
- A stale report from an earlier review of an older commit could certify the
  current one if the lint step were skipped. The report records its `headSha`
  (`git rev-parse HEAD`); compose-review rejects it as stale when it disagrees
  with the plan's `fetchedSha`.

* fix(review): third gate re-review — read the report before the owed-predicate, fail closed on all paths

- The gate returned early on `hasExecutableScript` (path-only), so a shebang
  script the command DID lint (`.husky/pre-commit`, detected by `#!`) had its
  findings dropped — the gate never read the report. Read the report first; the
  path-predicate now only gates the no-report fail-closed case. Findings from any
  script the report names are processed.
- The staleness guard was a no-op when `report.headSha` was absent (git
  unreadable): `planSha && report.headSha && …` short-circuited, so an
  unverifiable report certified new code. It now fails closed on a missing headSha
  when the plan names a commit.
- The plan-parse catch failed OPEN (returned "nothing owed") while every other
  path fails closed. It now discloses "could not read the plan" as unreviewed.
- hadolint had no config isolation. Point `HADOLINT_CONFIG` at an empty file so a
  PR-added `.hadolint.yaml` cannot suppress findings — an env var, benign if the
  tool ignores it (unlike the invalid `--no-config` flag from the last round).
- `buildNote` hardcoded "not installed" for every skipped file; `skipped` now
  mixes reasons (missing tool, irregular file, deferred checker), so it summarises
  by tool and leaves the specific reason on each entry.

* fix(review): R3 — deferred (non-capping) actionlint, local-flow staleness, disclosure

- actionlint deferral capped EVERY workflow-touching PR (≈15% of merges): a
  deferred workflow went to `skipped` → unreviewed → no Approve, on a checker
  present but deliberately not run, which "install the tool" cannot fix. Split a
  third `deferred` state — disclosed in the note, but the gate never reads it, so
  it does not cap. `skipped`/`errored` still cap.
- The staleness guard was PR-only: `fetchedSha` is written by fetch-pr, not
  capture-local, so a local review short-circuited the check and a stale local
  report could certify new code — the exact fail-open, in the local flow.
  capture-local now records the local HEAD as `fetchedSha`.
- The local path was armed (reviewMode `local`) but SKILL's `--worktree` was
  unfillable there. SKILL now covers a local review (worktree = the project root)
  and the derived report name.
- Nits: the skipped/irregular reasons no longer lead with the path (the gate
  prefixes it — was printing it twice); merged the two `./lib/roster.js` imports.
- Tests: deferred does not cap; the staleness guard protects a local review too.

* fix(review): R4 — disclose deferred lint without capping; bind report freshness to diff content

B1 — the deferred checker was silent. R3 split a third `deferred` state so a
workflow's embedded shell (actionlint, whose source-mapping this env can't
verify) no longer caps the verdict — but the disclosure half was never wired:
`scriptLintGate` returned only `{criticals, unreviewed}`, so a workflow-only PR
went quiet on a file no checker examined. That is #10's original complaint
("only disclosed in prose") returning as disclosed nowhere. Add a third channel:
the gate now returns `disclosed`, populated from `report.deferred`, and
`composeReview` renders it in the body on every verdict — including Approve —
without pushing it into `cappedBy`. SKILL.md's contract paragraph now documents
the deferred state alongside unreviewed scope.

B2 — the staleness guard keyed on HEAD. A local review is defined by uncommitted
work, so `git rev-parse HEAD` is not a content identity for what it reviews: two
reviews at the same HEAD with different working-tree content shared a `fetchedSha`
and a stale clean report could certify broken code. Bind freshness to the diff's
content instead: `runScriptLint` stamps the report with a sha256 of the captured
diff (`diffHashOf`), and the gate re-hashes the plan's current diff and rejects a
mismatch. Correct for a PR (a later commit → a different diff) and for local
uncommitted work alike. `capture-local`'s `fetchedSha` plumbing is removed.

Tests: the DEFERRED-only case now asserts the disclosure is present and
non-capping (it previously pinned the silence). New composeReview gate tests kill
the surviving mutants — the gate critical stands with no verifier (provenance,
not the `[lint]` marker), and an errored checker caps a would-be Approve to
Comment. New runScriptLint tests pin the diff-hash stamp. 972 review tests,
ESLint --max-warnings 0, tsc, Prettier clean.

* fix(review): R5 — freshness fails closed when neither side has a hash; pin config isolation

The staleness guard was `report.diffHash !== planDiffHash`. When the plan names no
readable diff AND the report carries no hash, both are `undefined` and
`undefined !== undefined` is false — so the guard did not fire and an arbitrary
hashless report was accepted as this review's, its findings promoted to `[lint]`
Criticals. Every other branch of this gate fails closed; this one inverted, on the
exact unverifiable case its own comment claimed to reject. Fixed with `!planDiffHash
|| …`, and the stale `headShaOf` JSDoc left stacked above `diffHashOf` is removed.

The gate's happy-path tests were green *because* of that hole: `writePlan`/
`writeReport` (and the composeReview `gateReadyPlan`/`writeGateReport`) wrote no
diff and no hash, so every test that didn't override them ran through the fail-open
branch — proving the gate works on an *unverifiable* report, not a fresh one. The
fixtures are now FRESH by default: a captured diff exists (`DIFF` is a real file;
coverage only string-matches it, so this is transparent to it) and both plan and
report bind to its hash. A freshness test overrides one side to model staleness, and
a new test pins the both-undefined case directly.

Also closes the last mutation gap R5 flagged three rounds running: the config
isolation (`--norc`, `SHELLCHECK_OPTS` scrub, `HADOLINT_CONFIG` → empty file) is a
security property — a PR-added linter config must not suppress the finding the gate
blocks on — but it lived inside `runTool`, behind the spawn an injected runner
bypasses. Extracted `buildToolInvocation` (pure argv + env) so all three defences
are asserted without a binary; each was hand-mutated to confirm its test fails when
the defence is deleted.

976 review tests, ESLint --max-warnings 0, tsc, Prettier clean.

* test(review): pin the plan-parse disclosure — the last surviving gate mutant

`scriptLintGate` on an unreadable plan fails closed and pushes its own reason into
`unreviewed`. The coverage machinery already caps an unreadable plan, so the verdict
and its cap are identical with or without this line — what it loses when deleted is
the specific "could not read the plan" sentence in the body. That made it a
disclosure guarantee with no test, and the one gate mutant left standing after five
rounds. Pin it directly on the gate. 977 review tests, ESLint/tsc/Prettier clean.

* fix(review): harden script-lint tmp-file + spawn, leak-proof the tests (R8 suggestions)

Four suggestions from the re-review, all on script-lint:

- Symlink race on the hadolint empty-config: it was written to a fixed
  `tmpdir()/qwen-review-hadolint-empty.yaml`, so on a shared runner a pre-planted
  symlink there would have `writeFileSync` follow it and truncate the target. Write
  it inside a fresh `mkdtempSync` directory instead — a 0700 dir with a random
  suffix that cannot pre-exist, so the write is safe and the path unpredictable.

- `runTool`'s `spawnSync` had no `timeout`, unlike the sibling runners in
  build-test.ts and test-efficacy.ts: a crafted script that hangs a linter would
  block the review until the outer CI job timeout reclaimed the runner. Added a
  120s bound; a timeout kills with SIGTERM, which the existing `r.signal` branch
  already turns into a fail-closed error.

- Two test-cleanup leaks: script-lint.test.ts's symlink test made a second temp
  dir cleaned only by an inline `rmSync` a failing assertion would skip, and
  script-lint.mock.test.ts used inline `fresh()`/`clean()` with no hook. Both now
  tear down in `afterEach`, which runs even when a test throws.

977 review tests, ESLint --max-warnings 0, tsc, Prettier clean.

* fix(review): hadolint isolation fails closed, not to a plantable path (R8)

R8 caught that the previous commit's fallback reopened the vector it closed. When
`mkdtempSync` failed, `emptyHadolintConfig` returned a FIXED
`tmpdir()/qwen-review-hadolint-none/x`. That path is a config hadolint *reads*, so an
attacker who plants an `ignored:` file there gets those ignores honoured — the
opposite threat direction from the write-truncation the mkdtemp move fixed. There is
now no predictable fallback: on failure `emptyHadolintConfig` returns `undefined`, the
env var is set only for a hadolint run and only when a private config exists, and
`runTool` fails the hadolint run CLOSED (errored) rather than lint against a config it
cannot vouch for.

Also from R8:
- The timeout comment claimed the `r.signal` branch handles a timeout. On Node a
  timeout sets BOTH `r.error` (ETIMEDOUT) and `r.signal` (SIGTERM), and `r.error` is
  checked first — so it is reported through the error branch. Comment corrected; still
  fail-closed either way.
- Both hardening changes were untested. `buildToolInvocation` now returns `timeoutMs`
  so the bound is one asserted value, and two tests pin it: HADOLINT_CONFIG is a fresh
  0700 mkdtemp path (not the old fixed name), and the timeout is 120s. Each fails under
  the corresponding mutation.
- The mkdtemp dir is swept at process exit, so the fixed-file-to-per-run-dir change
  does not leak into the OS tmpdir.

979 review tests, ESLint --max-warnings 0, tsc, Prettier clean.

* test(review): pin the hadolint fail-closed guard in its own file (R9)

The `if (tool === 'hadolint' && !emptyHadolintConfig())` guard is what the previous
commit exists to add, yet deleting it left the suite green: `emptyHadolintConfig`
caches at module scope, so by the time any test in script-lint.mock.test.ts runs the
cache is warm and the failure path is unreachable from that file. A dedicated file
gets a fresh module registry — it points TMPDIR at a path that does not exist before
importing, so the first `emptyHadolintConfig()` call fails to mkdtemp and the guard
fires. Two tests, split so a mutation attributes cleanly: (1) buildToolInvocation
sets no HADOLINT_CONFIG (the env logic, holds either way); (2) runScriptLint errors a
Dockerfile closed rather than lint it unisolated (the guard). Deleting the guard
reddens (2) and leaves (1) green.

Also from R9: register the process-exit cleanup right after mkdtempSync, before the
write, so a writeFileSync that throws still leaves the temp dir swept, not leaked.

981 review tests, ESLint --max-warnings 0, tsc, Prettier clean.

* fix(review): isolate hadolint via --config, not the ignored HADOLINT_CONFIG env (R10)

Verified against real hadolint 2.14.0: the binary does not read `HADOLINT_CONFIG`
(its `strings` list it among no `HADOLINT_*` config vars; `-V` reports "No
configuration was specified"). It reads `--config`, then a `.hadolint.yaml` in the
process CWD, then XDG. Because a local review runs with `--worktree .`, the linter
ran inside the reviewed tree and honoured the diff's OWN `.hadolint.yaml` — so the
env-based "isolation" was a silent no-op and a PR could suppress its own Dockerfile
findings (a DL3018 `ignored:` made the finding vanish with nothing disclosed).

Isolate on the channel hadolint actually reads: pass `--config <private neutral
file>`, which overrides both the cwd and XDG configs. The config content is now
`ignored: []` rather than empty — `--config` rejects an empty file ("empty YAML
stream"). The mkdtemp 0700 dir and the fail-closed guard are kept (the config is a
path hadolint reads, so both still matter); the no-op `HADOLINT_CONFIG` /
`HADOLINT_NO_COLOR` env vars are dropped.

Tests updated to assert the real channel: hadolint's argv carries `--config` at a
fresh 0700 mkdtemp path holding `ignored: []`, and the isolation-unavailable path
adds no `--config` (and still fails closed). shellcheck's `--norc` /
`SHELLCHECK_OPTS` isolation is unchanged — it was verified sound against the real
binary. 981 review tests, ESLint/tsc/Prettier clean.

* fix(review): address maintainer review — worktree containment, hunk-fallback false positive, zsh/shebang docs

From @yiliang114's review of the script-lint gate:

- P1 shebang/zsh: `toolFor` intentionally omits zsh (and fish) — shellcheck refuses
  both (`SC1071: ShellCheck only supports sh/bash/dash/ksh`), so routing a
  `#!/usr/bin/env zsh` hook to it would make every zsh file a bogus SC1071 `[lint]`
  Critical on its shebang. Documented the deliberate exclusion rather than adding zsh.

- P1 hunk fallback: `inDiff` fell back to the plan's context-inclusive hunks whenever
  `addedRanges.get(path)` missed — including when the diff parsed fine but a path was
  unmatched, which could promote a pre-existing finding on a context line to a
  blocker. Now a parsed diff that does not mention a path yields `[]` (nothing added
  → nothing blocks); the context-inclusive `hunksOf` fallback is used only when NO
  diff parsed at all (a report the freshness guard already rejects as stale), and that
  degraded path now logs a warning.

- P2 worktree containment: `resolve(join(worktree, path))` now refuses a path that
  escapes the worktree (`../../etc/passwd`), disclosing it as skipped rather than
  stat-ing/linting a file outside the reviewed tree.

- P2 roster/gate shebang gap: made explicit in the gate's no-report branch that a
  shebang-only script (which `hasExecutableScript` cannot see) is covered by the
  always-run contract, not the `owed` predicate.

Two items left as tracked follow-ups per the reviewer's own framing (both flagged
low-priority / design): an aggregate cross-file time budget (the per-file 120s bound
is the main defense), and per-severity finding classification (the v1 all-or-nothing
stance is documented and defensible). 983 review tests, ESLint/tsc/Prettier clean.

* fix(review): harden the script-lint gate against the adversarial review round

From the Codex GPT-5 /review pass. Five fixed:

- Provenance by IDENTITY, not count (compose-review): the gate's `[lint]` criticals
  are now tracked as a separate list rather than pushed into `bodyCriticals` and
  removed by a count subtraction. The old `(filtered) − gateCount` misfired when a
  model claim carried a `[build]/[test]/[probe]` tag (filtered out before the
  subtract) or a gate finding's own text contained one — erasing an unrelated
  claim's verification requirement. Model criticals alone decide the verify count.

- Distinguish unknown size from deletion (roster): `fileLines: 0` is a real
  post-image count only in pr-worktree; in local/diff-only the report builder writes
  0 for EVERY file, so keying deletion on it read a surviving `deploy.sh` as deleted
  and let a missing report pass uncapped. `hasExecutableScript` now trusts
  `fileLines` only in pr-worktree and owes any path-detected script otherwise.

- Hash the bytes used for range mapping (script-lint): the diff was read twice —
  ranges before the linters, hash after — a TOCTOU window where a concurrent
  recapture could pair snapshot A's ranges with snapshot B's hash. Read the diff
  once into one buffer and derive both from it; an unparseable diff now yields no
  `diffHash` (gate fails closed) instead of context-inclusive hunks.

- Escape PR-controlled paths in the body (compose-review): a diff filename can carry
  a newline, `@mention`, HTML or Markdown; the gate's criticals/unreviewed/disclosed
  strings rendered it verbatim into the posted body. Paths and linter messages now
  render in an inline code span with backticks and newlines stripped (`mdField`).

- Classify lstat failures (script-lint): only ENOENT/ENOTDIR is "missing" (deleted);
  EACCES/EIO/ELOOP is now `irregular` (skipped, fail closed) rather than silently
  read as a deletion into an `ok: true` "nothing changed".

Three left as reasoned follow-ups: verifier-provenance for `[build]/[test]/[probe]`
tags (a pre-existing verification-model concern, not this gate); actionlint's native
workflow diagnostics (deferring the whole tool loses them, but separating native
from embedded-shell output needs an actionlint-output parser this env can't verify);
and cryptographic authentication of the report file (the orchestrator runs the
deterministic command and overwrites any pre-planted file; full anti-forgery is a
broader harness-trust change). 985 review tests, ESLint/tsc/Prettier clean.

* fix(review): clean error from the script-lint handler on a bad plan

Wrap `runScriptLint` in the command handler in try/catch, matching sibling
build-test: a missing or invalid plan makes it throw, and yargs' default handler
would print a stack trace the orchestrator has to parse. Emit the one-line message
and a non-zero exit instead; the gate still fails closed on the absent report.

* fix(review): scrub HADOLINT_* env, canonicalize worktree paths, portable isolation tests

Follow-up adversarial round on the previous fixes:

- Scrub HADOLINT_* from the child env (script-lint): hadolint 2.14 MERGES config from
  HADOLINT_IGNORE / HADOLINT_OVERRIDE_* / HADOLINT_CONFIG with the explicit --config,
  so an inherited one could suppress the findings the neutral config forces. Drop
  every HADOLINT_* var, mirroring the SHELLCHECK_OPTS scrub — configuration comes
  from our --config alone. Hostile-env regression test added (this also makes the
  HADOLINT_CONFIG assertion hermetic under an inherited value).

- Canonicalize the worktree-containment check (script-lint): the lexical startsWith
  guard is defeated by a symlinked ANCESTOR — a directory replaced with a symlink
  leaves a child path lexically inside but resolving outside, and lstat/the linter
  follow it. Add a realpathSync check against the canonical worktree; a path that
  cannot be canonicalised is handled as missing downstream. Symlink-escape test added.

- Portable isolation tests: override TEMP/TMP as well as TMPDIR (os.tmpdir() ignores
  TMPDIR on Windows, leaving the fail-closed path unexercised there), and guard the
  0o700 mode-bit assertion with process.platform !== 'win32'.

Left tracked: fail-closed on a PARTIALLY parsed diff — narrow (capture writes the
diff in one writeFileSync, not incrementally) and already mitigated (a truncation
that later completes changes the hash, so the freshness guard rejects the report).
987 review tests, ESLint/tsc/Prettier clean.

---------

Co-authored-by: verify <verify@local>
@wenshao

wenshao commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at 3a1b506. No blocking issues found; targeted shell-command tests and CI are green.

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

Review at 3a1b506

Reviewed the full diff — the queue/drain machinery and the ! branch in the app component, the i18n changes, and all 24 new tests — and independently verified locally at this head: client/App.test.tsx 200/200 green.

What I checked, beyond reading the diff:

  • Lazy-creation fencing. Traced the microtask/effect ordering between clearPreparation, the submit's .then generation check, and the session-switch wipe effect. Both possible orderings are safe: if the wipe effect runs before clearPreparation, the preparingSessionIdRef exemption skips the bump; if it runs after, the inline command has already been dispatched and the late bump is a no-op. The dangerous interleaving (bump before the generation check with the preparing ref already cleared) cannot occur — the check and clearPreparation are adjacent microtasks that a React effect cannot interleave.
  • Drain single-flight + generation fencing. isDrainingRef prevents a second drain from cancelling an in-flight batch; the finally block only releases the lock if the generation is unchanged, so a cancel/switch that hands the lock to a newer drain isn't unblocked prematurely. FIFO across mid-drain enqueues holds.
  • Stuck-drain recoverability. sendShellCommand registers an AbortController under sessionId:shell (webui session actions), so Stop always aborts a hung command; the drain also checks status !== 'connected' between commands. This confirms the earlier decline of the never-settling-promise thread is sound.
  • Cancel semantics. Stop wipes the queue with a count toast, fences the in-flight inline command via generationAtSubmit, and resets shellSubmitInFlightRef — and a retry submitted while the old creation is still in flight safely dedupes onto the same creation promise (covered by a dedicated test).
  • No dangling references. queue.shellBlocked is fully removed; the new shellQueued/shellDropped keys exist in both EN and ZH.
  • Unresolved threads (2, both Suggestion-level, non-blocking): queue visibility/cancellation UI is documented as out of scope in the PR body and Stop provides an escape hatch; the stuck-drain concern is addressed per above.

The ensureSessionForPrompt return-type widening and the scheduleDelayedSessionListReload extraction are behavior-preserving for the existing prompt path.

No blocking issues. LGTM. ✅

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

Review at 3a1b506

Reviewed the full diff — the queue/drain machinery and the ! branch in the app component, the i18n changes, and all 24 new tests — and independently verified locally at this head: client/App.test.tsx 200/200 green.

What I checked, beyond reading the diff:

  • Lazy-creation fencing. Traced the microtask/effect ordering between clearPreparation, the submit's .then generation check, and the session-switch wipe effect. Both possible orderings are safe: if the wipe effect runs before clearPreparation, the preparingSessionIdRef exemption skips the bump; if it runs after, the inline command has already been dispatched and the late bump is a no-op. The dangerous interleaving (bump before the generation check with the preparing ref already cleared) cannot occur — the check and clearPreparation are adjacent microtasks that a React effect cannot interleave.
  • Drain single-flight + generation fencing. isDrainingRef prevents a second drain from cancelling an in-flight batch; the finally block only releases the lock if the generation is unchanged, so a cancel/switch that hands the lock to a newer drain isn't unblocked prematurely. FIFO across mid-drain enqueues holds.
  • Stuck-drain recoverability. sendShellCommand registers an AbortController under sessionId:shell (webui session actions), so Stop always aborts a hung command; the drain also checks status !== 'connected' between commands. This confirms the earlier decline of the never-settling-promise thread is sound.
  • Cancel semantics. Stop wipes the queue with a count toast, fences the in-flight inline command via generationAtSubmit, and resets shellSubmitInFlightRef — and a retry submitted while the old creation is still in flight safely dedupes onto the same creation promise (covered by a dedicated test).
  • No dangling references. queue.shellBlocked is fully removed; the new shellQueued/shellDropped keys exist in both EN and ZH.
  • Unresolved threads (2, both Suggestion-level, non-blocking): queue visibility/cancellation UI is documented as out of scope in the PR body and Stop provides an escape hatch; the stuck-drain concern is addressed per above.

The ensureSessionForPrompt return-type widening and the scheduleDelayedSessionListReload extraction are behavior-preserving for the existing prompt path.

No blocking issues. LGTM. ✅

@wenshao
wenshao added this pull request to the merge queue Jul 27, 2026
Merged via the queue into QwenLM:main with commit 738c500 Jul 27, 2026
92 checks passed

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

No issues found. LGTM! ✅

— qwen3.7-max via Qwen Code /review

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

No issues found. LGTM! ✅

中文说明

未发现问题。LGTM!✅

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.1.

@QwenLM QwenLM deleted a comment Aug 6, 2026
@QwenLM QwenLM deleted a comment Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants