Skip to content

feat(workflows): bubble workflow agent approvals - #8240

Merged
wenshao merged 5 commits into
QwenLM:mainfrom
qqqys:codex/issue-8105-workflow-approval-bubbling
Aug 1, 2026
Merged

feat(workflows): bubble workflow agent approvals#8240
wenshao merged 5 commits into
QwenLM:mainfrom
qqqys:codex/issue-8105-workflow-approval-bubbling

Conversation

@qqqys

@qqqys qqqys commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR completes the foreground Dynamic Workflow permission path. When a Workflow agent reaches a Shell, edit, MCP, or information request that needs confirmation, the request is parked on its owning run and surfaced through the parent TUI, ACP host, or stream-json control channel. The parent can allow the operation once or deny it; persistent approval choices are not offered or accepted.

Pending requests are bounded, identified independently across runs and agents, and settled exactly once. Terminal transitions, cancellation, retry cleanup, session disposal, and host failure all clear the request and fail closed. Public run state retains only the restricted review details needed by the approval UI; raw arguments and runtime responders remain process-local and approval state is omitted from snapshots.

ACP permission prompts are serialized at the shared connection boundary, including prompts from concurrent sessions, primary tools, nested agents, and Workflow agents. Workflow approval cards use a session-and-run-scoped transport identity so a late terminal update from one session cannot close another session's prompt. The bundled IDE host also keeps a denied Workflow child request isolated from the parent prompt and renders restricted edit diffs for review.

Why it's needed

Foreground Workflows currently stop indefinitely when a child agent requests permission because the parent session has no route to answer it. That gap would become more severe once runs can detach in a later phase, and silently auto-approving would widen permissions. This phase establishes the explicit, fail-closed response chain first while preserving the existing foreground execution and output contract.

Reviewer Test Plan

How to verify

  1. Start an experimental foreground Workflow whose agent attempts an operation that requires approval. Confirm the Workflow row and footer indicate that approval is needed, the detail view shows the same one-shot confirmation surface used by ordinary tools, and allowing it once resumes only that operation.
  2. Repeat and deny the request. Confirm the child operation does not run, the parent prompt remains active where the host supports it, and no approval remains parked after the Workflow settles or is cancelled.
  3. Open concurrent requests from multiple agents or ACP sessions. Confirm the prompts are presented one at a time, each decision reaches only its owning run, and a late terminal update cannot close a different session's prompt.
  4. Exercise stream-json with an explicit can_use_tool response and plain headless mode without a control channel. Confirm the former applies only an explicit one-shot allow (including sanitized updated input) and the latter denies rather than waiting forever.

Focused verification passed: Core 217 tests; CLI 733 tests with one pre-existing skip; WebUI 3 tests; VS Code companion 51 tests; root build, typecheck, lint, formatting, and diff checks. Real PTY scenarios cover approve at 100 columns, deny at 48 columns, and a 13-row roster scrolling in a 60x18 terminal, including live approval refresh and actual allow/deny side effects.

Evidence (Before & After)

Before: the Workflow agent entered an approval wait, but the parent Workflow surface had no pending request to render or resolve, so execution could remain blocked indefinitely.

After: the real terminal shows needs approval, routes focus from the footer into the Workflow row and shared confirmation view, updates the live agent count when the decision settles, restores the composer, and executes only the approved side effect. Detailed PTY evidence is posted as a separate PR comment.

Tested on

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

Environment (optional)

Production bundle in a real macOS PTY with deterministic local model responses; package-local Vitest suites for runtime, Ink, ACP, stream-json, WebUI, and the bundled IDE host. Windows and Linux are left to CI.

Risk & Scope

  • Main risk or tradeoff: approval routing now spans several parent transports, so the implementation deliberately serializes ACP prompts, uses run-scoped identities, bounds display state, and denies on every unknown or unavailable path.
  • Not validated / out of scope: background execution, pause/resume, durable recovery, persistent permission grants, live external model/network behavior, and local Windows/Linux terminals are not part of this phase.
  • Breaking changes / migration notes: none. Foreground Workflow execution remains the default and no persisted schema changes.

Linked Issues

Part of #8105

中文说明

本 PR 做了什么

本 PR 补齐前台 Dynamic Workflow 的权限响应链。当 Workflow Agent 遇到需要确认的 Shell、编辑、MCP 或信息请求时,请求会停放在所属 run 上,并通过父会话的 TUI、ACP Host 或 stream-json 控制通道展示。父会话只能单次允许或拒绝该操作,不提供也不接受持久授权选项。

待审批请求有数量上限,在不同 run 和 Agent 之间具有独立身份,并且只会结算一次。终态转换、取消、重试清理、Session 销毁和 Host 失败都会清理请求并按拒绝处理。公开 run 状态只保留审批 UI 所需的受限审阅信息;原始参数和运行时响应器仅存在于进程内,Snapshot 不保存审批状态。

ACP 权限请求在共享 Connection 边界串行化,覆盖并发 Session、主工具、嵌套 Agent 和 Workflow Agent。Workflow 审批卡使用包含 Session 与 run 的传输身份,因此一个 Session 的晚到终态更新不会关闭另一个 Session 的审批。内置 IDE Host 还会把 Workflow 子请求的拒绝限制在该子请求内,不取消父 Prompt,并能展示受限的编辑 diff 供用户审阅。

为什么需要

目前前台 Workflow 的子 Agent 一旦请求权限就可能无限停住,因为父会话没有回答入口。后续阶段引入后台运行后,这个缺口会更严重,而静默自动批准又会扩大权限。本阶段先建立显式、失败关闭的响应链,同时保持现有前台执行与输出契约不变。

Reviewer 测试计划

如何验证

  1. 启动一个 experimental 前台 Workflow,让其中的 Agent 尝试需要审批的操作。确认 Workflow 行和底部状态提示需要审批,详情页展示与普通工具一致的单次确认界面,并且单次允许后只有该操作继续执行。
  2. 再次执行并拒绝请求。确认子操作不会执行;在 Host 支持时父 Prompt 保持运行;Workflow 结束或取消后不残留待审批请求。
  3. 从多个 Agent 或 ACP Session 同时发起请求。确认审批按顺序展示,每个决定只到达所属 run,晚到的终态更新不会关闭另一个 Session 的审批。
  4. 分别验证带显式 can_use_tool 响应的 stream-json 和没有控制通道的普通 headless 模式。确认前者仅接受显式单次允许并应用安全更新后的输入,后者直接拒绝而不会永久等待。

聚焦验证已通过:Core 217 个测试;CLI 733 个测试,另有 1 个既有 skip;WebUI 3 个测试;VS Code companion 51 个测试;根目录 build、typecheck、lint、格式和 diff 检查均通过。真实 PTY 场景覆盖 100 列终端批准、48 列窄终端拒绝,以及 60x18 终端中 13 行任务列表滚动,并验证审批 live refresh 和真实允许/拒绝副作用。

前后对比证据

之前:Workflow Agent 进入审批等待,但父级 Workflow 界面没有可展示或响应的待审批请求,因此执行可能永久阻塞。

之后:真实终端显示 needs approval,焦点可以从底部状态进入 Workflow 行和共享确认视图,决定结算后实时更新 Agent 计数并恢复输入框,而且只有被允许的副作用会执行。详细 PTY 证据会作为独立 PR 评论发布。

已测试平台

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

环境(可选)

在真实 macOS PTY 中运行 production bundle,并使用确定性的本地模型响应;同时运行 Runtime、Ink、ACP、stream-json、WebUI 和内置 IDE Host 的 package-local Vitest。Windows 和 Linux 交给 CI 验证。

风险与范围

  • 主要风险或取舍:审批路由跨越多个父级传输,因此实现会串行化 ACP 请求、使用 run 级身份、限制展示状态大小,并在所有未知或不可用路径上拒绝。
  • 未验证或不在范围内:后台执行、暂停/恢复、持久恢复、持久授权、真实外部模型/网络行为,以及本地 Windows/Linux 终端不属于本阶段。
  • Breaking change / 迁移说明:无。前台 Workflow 仍是默认行为,不涉及持久化 Schema 变化。

关联 Issue

Part of #8105

@qqqys

qqqys commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Real terminal E2E report

Result: PASS for the foreground Ink/TUI approval path on the final production bundle.

  • Approve, 100 columns: a real Workflow agent stopped on Shell approval; the footer and Workflow row showed needs approval; Down focused the footer; Enter opened the list, detail, and Yes, allow once; approval executed the command and produced a proof file containing approve; the live row refreshed from 0/1 ⚠ to 1/1; the parent returned PARENT_APPROVE_DONE; Escape closed the dialog and restored the composer.
  • Deny, 48 columns: the narrow terminal kept the wrapped approval marker, command, and options readable; Escape denied the request; the proof file was not created; the Workflow settled to 1/1, the parent completed, and the composer recovered without a hang.
  • Scroll, 60x18: 12 real background-shell rows plus one waiting Workflow produced a 13-row roster. Nine Down presses changed the viewport from 12 more below to 9 more above / 3 more below and moved focus with the window.
  • Focus, shortcuts, and live refresh: Down → footer; Enter → list/detail/approve; Escape → deny/close; 0/1 ⚠1/1 without reopening the dialog.

The driver used the final bundled CLI through a real node-pty and headless xterm/Ink terminal. Each approval scenario completed four deterministic model round trips, exited 0, left no child process, and produced no tracked or untracked repository artifact.

Boundary: the model side used the repository's local fake OpenAI server with an isolated temporary home; no standard provider API key was present. This evidence covers the production bundle, PTY layout/focus/key handling, Workflow registry and approval routing, real Shell confirmation/execution/denial, and cleanup. It does not cover live-provider authentication, network, or latency; ACP and stream-json host behavior is covered by focused protocol regressions.

中文说明

结果:最终 production bundle 的前台 Ink/TUI 审批链真实终端 E2E 全部通过。

  • 批准,100 列:真实 Workflow Agent 停在 Shell 审批;底部和 Workflow 行显示 needs approval;Down 聚焦底部,Enter 依次进入列表、详情和 Yes, allow once;批准后命令执行并生成内容为 approve 的证明文件;行状态实时从 0/1 ⚠ 更新为 1/1;父会话返回 PARENT_APPROVE_DONE;Escape 关闭弹层并恢复输入框。
  • 拒绝,48 列:窄终端仍能换行展示审批标记、命令和选项;Escape 拒绝后没有生成证明文件;Workflow 正常结算到 1/1,父会话完成,输入框恢复且无 hang。
  • 滚动,60x18:12 个真实后台 Shell 行加 1 个待审批 Workflow 形成 13 行列表。连续 9 次 Down 后,窗口提示从 12 more below 变为 9 more above / 3 more below,焦点随窗口正确移动。
  • 焦点、快捷键和实时刷新:Down → 底部;Enter → 列表/详情/批准;Escape → 拒绝/关闭;无需重开弹层即可看到 0/1 ⚠1/1

Driver 通过真实 node-pty 和 headless xterm/Ink 运行最终 bundle。每个审批场景完成 4 次确定性模型 round trip,退出码为 0,没有残留子进程,也没有产生 tracked 或 untracked 仓库文件。

边界:模型侧使用仓库本地 fake OpenAI server,并隔离到临时 Home;没有配置标准 Provider API Key。本证据覆盖 production bundle、PTY 布局/焦点/按键、Workflow Registry 与审批路由、真实 Shell 确认/执行/拒绝及清理;不覆盖真实 Provider 的认证、网络和延迟。ACP 与 stream-json Host 行为由聚焦协议回归测试覆盖。

@qqqys qqqys added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Jul 31, 2026
@qwen-code-ci-bot

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

Screenshots · before / after

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

  • packages/webui/src/components/PermissionDrawer.tsx

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

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

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 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 31, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this is the foreground permission path for Dynamic Workflows, and it reads well.

Template looks good ✓ — all sections present, bilingual body, a real reviewer test plan.

Problem: real and observed, not theoretical. A foreground Workflow child agent that hits a permission-requiring tool call currently parks with no route for the parent session to answer it, so the run can block indefinitely. This is phase PR1A of the Dynamic Workflows roadmap (#8105, a coding-plan issue), and the same author already shipped the related shutdown-abort fix (#8107). The motivation holds up.

Direction: aligned. Establishing an explicit, fail-closed response chain before background execution and run-detach land is exactly the right order — auto-approving to close the gap would widen permissions, and deferring the chain would make the later phases worse. CHANGELOG has no entry for this exact feature yet, but the area is an active, shipping roadmap item (workflow abort #8107, channel pairing approvals #8081, background-agent roster work), so this is core territory rather than a tangent.

Size: this touches core infrastructure (packages/core/src/agents/**) and spans four packages (core, cli, vscode-ide-companion, webui). Breakdown: 800 production logic lines, 2016 test lines, 0 generated/schema. It's a feat, so there's no hard block — but at 500+ production lines on core paths this needs maintainer awareness, which I'm flagging here per the core-module gate. (Not yet at the 1000-line large-PR advisory.)

Approach: the scope feels right rather than bloated. The five transport surfaces (TUI dialog/pill, ACP, stream-json control channel, IDE host, webui) look like a lot, but an approval that can't reach a user through whichever surface is driving the session is an undeliverable feature — cutting to just the registry would leave the chain broken. I didn't spot drive-by refactors or unrelated churn; the PermissionDrawer planText→contentText generalization is the one shared refactor and it's load-bearing for the edit-diff display. One thing worth a maintainer's eye: the ACP serialization queue (permissionRequestTails WeakMap) is now shared by primary tools, nested agents, and workflow approvals on one connection — that's the intended design, but it's the kind of shared-state change that's worth a second look.

Risk: Stage 1e flagged a high-risk path — this PR modifies packages/cli/src/acp-integration/session/Session.ts, and acp-integration is one of the paths correlated with post-merge reverts in this repo. That's not a blocker, but it means I'm not skipping any review depth and I'm requiring the PR's own CI evidence before any sign-off.

Flagging the core-size escalation and the acp-integration risk for discussion; moving on to a full code review. 🔍

中文说明

感谢贡献——这是 Dynamic Workflows 的前台权限路径,整体读起来很扎实。

模板 完整 ✓——各节齐全,双语正文,有真实的 reviewer 测试计划。

问题: 真实且已观测,不是理论性的。前台 Workflow 子 Agent 一旦遇到需要审批的工具调用,目前会停住,而父会话没有回答入口,因此 run 可能无限阻塞。这是 Dynamic Workflows 路线图(#8105coding-plan issue)的 PR1A 阶段,同一作者已经合入了相关的 shutdown-abort 修复(#8107)。动机成立。

方向: 对齐。在后台执行和 run-detach 落地之前先建立显式、失败关闭的响应链,顺序完全正确——用自动批准来堵这个缺口会扩大权限,推迟响应链则会让后续阶段更糟。CHANGELOG 还没有这个具体功能的条目,但该领域是正在持续交付的路线图项(workflow abort #8107、channel pairing approvals #8081、background-agent roster 等),属于核心地带而非旁支。

规模: 触及核心基础设施(packages/core/src/agents/**),并跨越四个 package(core、cli、vscode-ide-companion、webui)。拆分:800 行生产逻辑、2016 行测试、0 行生成/schema。它是 feat,所以没有硬阻断——但在核心路径上达到 500+ 生产行,按核心模块门禁需要维护者关注,我在此明确标出。(尚未到 1000 行的大 PR 建议线。)

方案: 范围合理,不臃肿。五个传输面(TUI dialog/pill、ACP、stream-json 控制通道、IDE host、webui)看起来很多,但一个无法通过驱动会话的任一界面到达用户的审批,等于无法交付的功能——只保留 registry 会让响应链断裂。没有发现顺手重构或无关改动;PermissionDrawer 的 planText→contentText 泛化是唯一一处共享重构,且是 edit-diff 展示所必需的。有一处值得维护者留意:ACP 串行化队列(permissionRequestTails WeakMap)现在被一条连接上的主工具、嵌套 Agent 和 Workflow 审批共享——这是预期设计,但这类共享状态改动值得再看一眼。

风险: Stage 1e 命中高风险路径——本 PR 修改了 packages/cli/src/acp-integration/session/Session.ts,而 acp-integration 是本仓库中与合并后 revert 相关的路径之一。这不是阻断项,但意味着我不会跳过任何 review 深度,并且在任何签核前会要求 PR 自身的 CI 证据。

先标出核心规模升级与 acp-integration 风险供讨论;进入完整代码审查。🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 53553912dd53c0ae9e021aa8825f2aff28a24fe6 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

My independent take before reading the diff: park the child's permission request on the owning run with a unique id, expose a restricted display DTO to the parent surface, route the one-shot decision back through a responder, and fail closed on every terminal transition. The PR matches this and goes further than I would have bothered to on a first pass — bounded pending list (32), a 64 KB display cap, an exhaustive type restriction with a never fallthrough, exactly-once settlement, and a per-connection ACP serialization queue that advances even when an RPC is orphaned by abort.

No critical blockers and no AGENTS.md violations found. The security posture is the standout:

  • Fail closed everywhere. parkPendingApproval rejects when the entry is missing, not running, or has no host channel; terminal transitions (complete/fail/cancel/abortAll/reset) all drain pending approvals to Cancel; an async host-channel failure resolves to Cancel; a sync throw cleans up and rejects; a respond that throws fails the whole run and aborts its controller. Plain headless mode never registers a channel, so a workflow approval there denies instead of hanging.
  • One-shot only. normalizeWorkflowApprovalOutcome maps every outcome except ProceedOnce/Cancel to Cancel, and the payload is forwarded only when the outcome is unchanged — a persistent grant can't be smuggled in. The ACP side independently rejects an unoffered persistent outcome.
  • Restricted public surface. The WorkflowApproval DTO never carries raw args or the runtime respond; edit details are projected with originalContent: null, newContent: '', and hideAlwaysAllow/hideModify/skipIdeDiff forced on. toSnapshot never projects pendingApprovals. Tests assert all of this with sentinels.
  • Identity isolation. Approvals are keyed by an independent approvalId, so two agents sharing a provider callId settle independently, and the ACP toolCallId is scoped workflow:<session>:<run>:<approval> so a late terminal update can't close another session's prompt.

The UI layer reuses the existing background-agent approval pattern rather than forking it (BackgroundTasksDialog routes by approvalId, not the shareable callId), and the IDE host keeps a denied workflow child from cancelling the parent prompt via the _meta.workflowApproval flag.

Nothing to fix before merge from a correctness standpoint. The only thing I'd want a maintainer to confirm is the shared ACP queue noted in Stage 1 — it's intentional, but it's the one piece of genuinely new shared state.

sequenceDiagram
    participant P1 as Workflow Agent
    participant P2 as WorkflowRunRegistry
    participant P3 as Host Channel
    participant P4 as User
    participant P5 as Child Tool Call
    P1->>P2: emit TOOL_WAITING_APPROVAL
    P2->>P2: park, restrict DTO, bound and dedup
    P2->>P3: approval request callback
    P3->>P4: surface one-shot prompt
    P4->>P3: allow once or deny
    P3->>P2: resolvePendingApproval by approvalId
    P2->>P5: respond, resume or cancel
    Note over P2: terminal transition drains pending, fail closed
Loading
Files changed (16 of 28 shown)
File What changed
packages/core/src/agents/workflow-run-registry.ts New approval park/resolve engine: bounded pending list, restricted DTO, exactly-once settlement, fail-closed drains
packages/core/src/agents/runtime/workflow-runner.ts Wires the registry approval bridge into production dispatch only
packages/core/src/agents/runtime/workflow-orchestrator.ts Installs and cleans up the approval bridge per stalled attempt
packages/cli/src/acp-integration/session/Session.ts ACP approval bridging with a per-connection serialization queue, fails closed on timeout and error
packages/cli/src/acp-integration/session/SubAgentTracker.ts Accepts an injectable permission requester so subagent prompts share the queue
packages/cli/src/nonInteractive/control/controllers/permissionController.ts stream-json can_use_tool handler for workflow approvals, explicit allow only
packages/cli/src/nonInteractive/control/ControlService.ts Exposes handleWorkflowApproval on the permission service
packages/cli/src/nonInteractive/control/types/serviceAPIs.ts Adds handleWorkflowApproval to the permission API type
packages/cli/src/nonInteractiveCli.ts Registers and clears the approval channel only when a control service exists
packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx Renders the workflow needs-approval row and routes decisions by approvalId
packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx Pill marks workflows with parked approvals
packages/cli/src/ui/hooks/useBackgroundTaskView.ts Subscribes to workflow approval-change events
packages/cli/src/ui/commands/workflowsCommand.ts Initializes pendingApprovals on snapshot-to-task mapping
packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts Isolates a denied workflow child from the parent prompt, settles by toolCallId
packages/webui/src/components/PermissionDrawer.tsx Renders restricted edit diffs as review text
…and 13 test files Collocated coverage for every path above, including sentinel leak checks

Test evidence (the PR's own CI)

I did not run any PR code — this is a static review, and the signal below is the PR's own CI on the reviewed commit. The ubuntu unit suite is green; macOS, Windows, and the CLI integration suite are skipped, which is the normal fork-PR pattern (no secrets on pull_request from a fork), not a failure. review-pr is the bot orchestration job, not PR CI.

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
Real daemon E2E / Java 11 success
Test (macos-latest, Node 22.x) skipped
Test (windows-latest, Node 22.x) skipped
Integration Tests (CLI, No Sandbox) skipped

The author reports focused suites passing locally (Core 217, CLI 733, WebUI 3, VS Code companion 51) plus real-PTY approve/deny scenarios — that's the author's claim, posted as a separate PR comment, not something I re-ran.

Sandboxed verification would settle the remaining gap: @qwen-code /verify — that a workflow child approval actually resumes only the approved operation end-to-end (and that plain headless denies rather than hangs) is a behavioural claim the green ubuntu suite mocks at the registry boundary, and macOS/Windows/integration CI is skipped. /tmux would additionally capture the real-terminal "needs approval" surface, but it gates on the author; /verify runs as a sponsored run — a maintainer's @qwen-code /verify approves the head it's written against and carries a pre-execution risk screen plus a workspace wipe, so read the resulting report with the same skepticism as the fork's own CI logs. Note @wenshao already ran a local verify on this PR.

中文说明

代码审查

读 diff 之前我的独立方案:把子 Agent 的权限请求停放在所属 run 上并赋予唯一 id,向父级界面暴露一个受限的展示 DTO,通过 responder 回传单次决定,并在所有终态转换上失败关闭。PR 与之一致,而且比我第一遍会做的更彻底——有界待审批列表(32)、64 KB 展示上限、带 never 兜底的穷举类型限制、恰好一次结算,以及一条即使在 RPC 被 abort 孤立后仍能推进的连接级 ACP 串行化队列。

未发现关键阻断项,也未发现 AGENTS.md 违规。安全姿态是亮点:

  • 处处失败关闭。 parkPendingApproval 在 entry 缺失、非 running 或没有宿主通道时拒绝;终态转换(complete/fail/cancel/abortAll/reset)都会把待审批排空为 Cancel;异步宿主通道失败解析为 Cancel;同步抛错会清理并拒绝;respond 抛错会让整个 run 失败并 abort 其 controller。普通 headless 模式根本不注册通道,因此那里的 workflow 审批会被拒绝而非挂起。
  • 仅单次。 normalizeWorkflowApprovalOutcome 把除 ProceedOnce/Cancel 外的所有结果映射为 Cancel,且 payload 仅在结果不变时转发——持久授权无法被夹带进来。ACP 侧也独立拒绝未被提供的持久结果。
  • 受限公开面。 WorkflowApproval DTO 从不携带原始 args 或运行时 respond;edit 详情以 originalContent: nullnewContent: '' 投影,并强制 hideAlwaysAllow/hideModify/skipIdeDifftoSnapshot 从不投影 pendingApprovals。测试都用 sentinel 断言了这些。
  • 身份隔离。 审批以独立 approvalId 为键,因此共享 provider callId 的两个 Agent 会独立结算;ACP 的 toolCallId 作用域为 workflow:<session>:<run>:<approval>,所以晚到的终态更新不会关闭另一个 session 的审批。

UI 层复用了既有的 background-agent 审批模式而非另起炉灶(BackgroundTasksDialogapprovalId 路由,而非可共享的 callId),IDE host 通过 _meta.workflowApproval 标志让被拒的 workflow 子请求不取消父 prompt。

从正确性角度没有合并前必须修的东西。我唯一想让维护者确认的是 Stage 1 提到的共享 ACP 队列——它是有意为之,但确实是唯一一处真正新增的共享状态。

(时序图与文件清单见英文部分。)

测试证据(PR 自身 CI)

我没有运行任何 PR 代码——这是静态审查,下面是 PR 自身在被审 commit 上的 CI 信号。ubuntu 单测套件为绿;macOS、Windows 和 CLI 集成套件为 skipped,这是 fork PR 的正常模式(fork 的 pull_request 没有 secrets),不是失败。review-pr 是 bot 编排任务,不是 PR CI。CI 表格见英文部分的机器可读区域。

作者报告本地聚焦套件通过(Core 217、CLI 733、WebUI 3、VS Code companion 51)以及真实 PTY 的批准/拒绝场景——这是作者的陈述,作为独立 PR 评论发布,不是我重新跑的结果。

沙箱验证可以补齐剩余缺口:@qwen-code /verify——workflow 子审批是否真的只恢复被批准的操作(以及普通 headless 是否拒绝而非挂起)是一个行为性声明,而绿的 ubuntu 套件在 registry 边界做了 mock,且 macOS/Windows/集成 CI 被跳过。/tmux 还能捕获真实终端的 "needs approval" 界面,但它以作者为准入门槛;/verify 以 sponsored run 运行——维护者的 @qwen-code /verify 会批准其写入时的 head,并带执行前风险筛查与工作区清理,因此请以与 fork 自身 CI 日志相同的怀疑态度阅读其报告。注意 @wenshao 已经对本 PR 跑过一次本地 verify。

Qwen Code · qwen3.8-max-preview

Reviewed at 53553912dd53c0ae9e021aa8825f2aff28a24fe6 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review across every stage, but the Stage 0 core-module escalation (a feat touching core at 800 production lines) caps this at 3/5 and needs a maintainer's sign-off; the score reflects policy, not doubt about the code.

Stepping back: this is genuinely good work. The motivation is real (foreground Workflow children block indefinitely on a permission request with no answer route), the fix is the right foundational move before background/detach phases land, and the implementation is more rigorous than the feature strictly demanded — exactly-once settlement, fail-closed on every terminal path, a restricted DTO that strips raw args and edit contents, persistent outcomes normalized to cancel, and identity isolation so shared callIds and cross-session prompts can't cross-wire. My independent proposal was a weaker version of what's here. The test coverage is the kind I'd hold up as an example — sentinel leak checks, shared-callId isolation, sync-throw and async-failure arms, bounding, and snapshot exclusion.

If I had to maintain this in six months I'd thank the author, not curse them. I'm not approving it myself for one reason only: it's a cross-package feat that adds 800 production lines to packages/core/src/agents/**, and our core-module gate routes that to a maintainer rather than auto-approving it. That's the gate working as intended on a large core feature, not a finding against this PR.

⏸️ Deferring to @wenshao — the code is ready as far as I can tell; this needs a human maintainer call on two things the gate can't settle: (1) sign-off on a core-touching feat of this size per the Stage 0 escalation, and (2) a second look at the shared ACP serialization queue (permissionRequestTails) now carrying primary tools, nested agents, and workflow approvals on one connection. The acp-integration high-risk path is the other reason I'd want a maintainer's eye rather than a bot approval. A /verify sponsored run would close the behavioural gap before merge (see Stage 2).

中文说明

置信度:3/5——每个阶段都是干净的审查,但 Stage 0 核心模块升级(一个触及核心、达 800 生产行的 feat)把它封顶在 3/5,需要维护者签核;这个分数反映的是策略,而非对代码的怀疑。

退一步看:这是真正出色的工作。动机真实(前台 Workflow 子 Agent 会在权限请求上无限阻塞,因为没有回答入口),修复方向正确——在后台/detach 阶段落地之前先打好这个基础,而且实现比功能本身要求的更严谨——恰好一次结算、所有终态路径失败关闭、剥离原始参数与编辑内容的受限 DTO、把持久结果归一为 cancel、以及身份隔离使共享 callId 与跨 session 审批不会串线。我的独立方案只是这里的一个更弱版本。测试覆盖是我愿意当作范例的水平——sentinel 泄漏检查、共享 callId 隔离、同步抛错与异步失败分支、有界限制、以及 snapshot 排除。

如果六个月后由我来维护,我会感谢作者而不是骂他。我不自己批准,只有一个原因:这是一个跨 package 的 feat,向 packages/core/src/agents/** 增加了 800 生产行,而我们的核心模块门禁会把它转交维护者,而非自动批准。这是门禁在大型核心功能上按预期工作,而不是对本 PR 的负面结论。

⏸️ 转交 @wenshao——就我所能判断,代码已经就绪;这需要人类维护者在两件门禁无法裁定的事上拍板:(1)按 Stage 0 升级,对一个这种规模、触及核心的 feat 签核;(2)再看一眼共享 ACP 串行化队列(permissionRequestTails)——它现在在一条连接上承载主工具、嵌套 Agent 和 Workflow 审批。acp-integration 高风险路径也是我希望由维护者过目、而非 bot 批准的另一个原因。合并前一次 /verify sponsored run 可以补齐行为性缺口(见 Stage 2)。

Qwen Code · qwen3.8-max-preview

Reviewed at 53553912dd53c0ae9e021aa8825f2aff28a24fe6 · re-run with @qwen-code /triage

@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

qwen-code-dev-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

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

中文说明

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

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

No code change was made this round. The feedback newer than the last evaluation contains no actionable findings:

  • Reviews: none.
  • Inline comments: none.
  • Failed checks / still-red checks: none.
  • Issue-level comments: two, both informational:
    • @qqqys — real terminal E2E report (PASS). Confirms the foreground Ink/TUI approval path on the final production bundle: approve at 100 columns, deny at 48 columns, scrolling a 13-row roster at 60x18, and focus/shortcut/live-refresh behavior all passed, with clean exit codes and no leftover processes or repository artifacts. This is positive verification of the PR's behavior and requests no change.
    • @qwen-code-ci-bot — web-shell visual preview (advisory). Reports no screenshot changed against the PR base and notes that packages/webui/src/components/PermissionDrawer.tsx is render-shaping but no visual scenario renders the new UI, so the preview cannot capture it. This is an automated coverage observation, not a defect or a maintainer change request. The PermissionDrawer.tsx change adds an edit/write content-review panel and is covered by PermissionDrawer.test.tsx. Adding a web-shell visual scenario to seed that state would be out-of-scope diff growth for an advisory note, so it is declined under the Simplicity First rule.

With no defects, no change requests, and no failing checks, there is nothing to fix this round.

中文说明

Autofix 审查轮次 —— 无需处理

本轮未做任何代码改动。上次评估之后的新反馈中没有可处理的发现:

  • Reviews(审查): 无。
  • Inline comments(行内评论): 无。
  • 失败检查 / 持续失败的检查: 无。
  • Issue 级评论: 两条,均为信息性:
    • @qqqys —— 真实终端 E2E 报告(通过)。 确认最终 production bundle 上的前台 Ink/TUI 审批链路:100 列下批准、48 列下拒绝、60x18 下滚动 13 行列表,以及焦点/快捷键/实时刷新行为全部通过,退出码干净,没有残留进程或仓库产物。这是对本 PR 行为的正向验证,未提出任何改动请求。
    • @qwen-code-ci-bot —— web-shell 视觉预览(提示性)。 报告相对 PR base 没有截图变化,并指出 packages/webui/src/components/PermissionDrawer.tsx 属于影响渲染的文件,但没有任何视觉场景渲染出新 UI,因此预览无法捕获它。这是一条自动化的覆盖率观察,并非缺陷,也不是维护者的改动请求。PermissionDrawer.tsx 的改动新增了一个 edit/write 内容审查面板,并由 PermissionDrawer.test.tsx 覆盖。为这条提示性说明去新增一个 web-shell 视觉场景来构造该状态,属于超出范围的 diff 膨胀,因此按 Simplicity First 原则予以拒绝。

由于没有缺陷、没有改动请求、也没有失败的检查,本轮没有任何需要修复的内容。

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


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

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

✅ Independent local build + real-PTY verification of PR #8240

Verdict: PASS — looks ready to merge. Maintainer-side local reproduction complementing the author's own PTY report. Each terminal state below was captured with the repo's terminal-capture harness (node-pty + xterm.js + Playwright); the observed TUI strings are quoted verbatim (the PNG captures are retained locally and quoted here so this reference stays self-contained).

Environment. Verified in an isolated git worktree pinned to PR head acc6694 — the primary checkout was being switched across branches concurrently by another process during this run (visible in git reflog), so a dedicated worktree kept the build uncontaminated. Production bundle built from source (npm cipatch-package for the ink patch → npm run build && npm run bundledist/cli.js, --version = 0.21.2). Real macOS PTY against a zero-dependency mock OpenAI server; QWEN_CODE_ENABLE_WORKFLOWS=1, --approval-mode default.

Build & unit tests

  • Production bundle: built clean from PR source. Only friction was environmental (stale tsbuildinfo from the prior branch; ink patch skipped by npm ci --ignore-scripts) — both fixed without touching PR code.
  • Suites for the files this PR changes: core 183/183 ✓, webui 3/3 ✓, vscode-ide-companion 51/51 ✓, cli 691 passed / 1 skipped / 2 failed (694).
  • The 2 CLI failures (Session > pins durable cron startup, prompt restart, and stop to the session runtime and Session > prompt > runs prompt inside runtime output dir context) are pre-existing & unrelated: they assert the fix(serve): isolate managed memory by selected workspace #8056 workspace-runtime-dir isolation; the PR diff touches neither those tests nor any runtime-dir path in Session.ts, so they behave identically on the base and fail here only due to local ~/.qwen resolution. Not a feat(workflows): bubble workflow agent approvals #8240 regression.

Real PTY — foreground Workflow approval bubbling (headline feature)

The mock makes the parent call the workflow tool; the child agent then requests a Shell command (echo approve > /tmp/wf8240-proof.txt) needing confirmation. In default mode that request must bubble to the parent TUI, not hang or auto-approve. Both branches verified end-to-end:

Approve path.

  1. The parked request surfaces on the footer pill: ▷ workflow active · 1 workflow ⚠ 待审批 (i.e. needs approval). The workflow tool's streamed result shows the live run state {"runId":"wf_…","status":"running","agentsDispatched":1,"agentsCompleted":0}.
  2. Focusing the pill (↓) and opening the run's detail view renders the restricted command and the same one-shot surface used by ordinary tools:
    Workflow › wf_…   ·  0/1 agents · 0 phases
    [workflow] 待审批
        echo approve > /tmp/wf8240-proof.txt
    是否继续?
    > 1. 是,允许一次     2. 否
    请批准或拒绝上方的请求 · ← back · x stop
    
  3. Allowing once executes only that command: the proof file is created with content approve; the run settles (1 task done); the workflow tool returns "result": "SUBAGENT_PROOF_CREATED"; the parent prints ◆ PARENT_APPROVE_DONE and the composer is restored.

Deny path. Selecting 2. 否 (> moves onto it) in the same surface denies the request: the Shell command does not run (proof file absent), no approval stays parked after the run settles, and the composer / parent prompt recovers (◆ PARENT_APPROVE_DONE, 1 task done) — fail-closed, as designed.

Methodology notes

  • The mock fingerprints the workflow subagent by its system prompt (spawned by a workflow orchestration) so parent vs. child requests are routed independently; the parent's own workflow tool was auto-allowed via a workspace .qwen/settings.json so that only the child's Shell request exercises the bubbling path.
  • First pass used the wrong wire name (Workflow vs. the registered workflow); once corrected, the approve and deny chains ran end-to-end (approve 32.8s, deny 38.3s).
中文说明(维护者独立本地构建 + 真实 PTY 验证)

结论:通过,可合并。 维护者侧本地复现,补充作者 PTY 报告。下列每个终端态均用仓库 terminal-capture harness(node-pty + xterm.js + Playwright)截图留存;此处逐字引用观察到的 TUI 字符串,使本参考自包含。

环境。隔离 git worktree 中验证,固定 PR head acc6694——运行期间主工作区被另一进程并发切分支(见 git reflog),故用独立 worktree 防污染。源码构建 production bundle(npm cipatch-package 应用 ink 补丁 → npm run build && npm run bundledist/cli.js--version=0.21.2)。真实 macOS PTY 对接零依赖 mock OpenAI server;QWEN_CODE_ENABLE_WORKFLOWS=1--approval-mode default

构建与单测

  • Production bundle:PR 源码干净构建成功。 阻力仅环境性(上分支遗留陈旧 tsbuildinfonpm ci --ignore-scripts 跳过 ink 补丁),均不改 PR 代码即解决。
  • 本 PR 改动文件对应单测:core 183/183 ✓webui 3/3 ✓vscode-ide-companion 51/51 ✓cli 691 通过 / 1 跳过 / 2 失败(694)
  • 2 个 CLI 失败(Session > pins durable cron startup……runs prompt inside runtime output dir context)为既有、无关:断言 fix(serve): isolate managed memory by selected workspace #8056 工作区 runtime 目录隔离;PR diff 未改这两测试,也未触及 Session.ts 任何 runtime 目录路径,base 上行为相同,此处仅因本机 ~/.qwen 解析失败。feat(workflows): bubble workflow agent approvals #8240 回归。

真实 PTY——前台 Workflow 审批冒泡(核心特性)

mock 使父会话调用 workflow 工具;子 agent 随后请求需确认的 Shell(echo approve > /tmp/wf8240-proof.txt)。default 模式下该请求必须冒泡到父 TUI,而非挂死/自动批准。两分支端到端验证:

批准路径。 ① 停放请求在底部 pill 显示 ▷ workflow active · 1 workflow ⚠ 待审批;workflow 工具流式结果展示 run 状态 {"runId":"wf_…","status":"running","agentsDispatched":1,"agentsCompleted":0}。② 聚焦 pill(↓)并打开 run 详情,渲染受限命令与一次性确认界面:[workflow] 待审批 / echo approve > /tmp/wf8240-proof.txt / 是否继续? / > 1. 是,允许一次 2. 否 / 请批准或拒绝上方的请求 · ← back · x stop。③ 单次允许后执行该命令:proof 文件写入 approve;run 结算 1 task done;workflow 工具返回 "result":"SUBAGENT_PROOF_CREATED";父打印 ◆ PARENT_APPROVE_DONE,输入框恢复。

拒绝路径。 在同一界面选 2. 否 即拒绝:Shell 执行(proof 文件不存在),run 结算后无残留审批,输入框/父 prompt 恢复(◆ PARENT_APPROVE_DONE1 task done)——按设计 fail-closed。

方法说明

  • mock 以子 agent system prompt(spawned by a workflow orchestration)做指纹,独立路由父/子请求;父自身 workflow 工具经工作区 .qwen/settings.json 自动放行,使子 Shell 请求走冒泡路径。
  • 首轮误用 wire 名(Workflow vs 注册名 workflow);修正后批准/拒绝链路均端到端跑通(批准 32.8s,拒绝 38.3s)。

Qwen Code · maintainer local verification

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

No action required

The only new feedback since the last evaluation is a maintainer's independent local build + real-PTY verification report (@wenshao) with a PASS verdict: "looks ready to merge." The report confirms both the approve and deny paths of the workflow approval bubbling feature work correctly end-to-end.

There are no review comments, no inline findings, no failed checks, and no still-red checks to address. No code changes are needed.

中文说明

无需操作

自上次评估以来,唯一的新反馈是维护者(@wenshao)的独立本地构建 + 真实 PTY 验证报告,结论为 通过:"可合并"。该报告确认 workflow 审批冒泡功能的批准和拒绝两条路径均端到端正常工作。

没有审查评论、没有行内发现、没有失败的检查、也没有持续红色的检查需要处理。无需代码变更。

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


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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 10800 seconds (of the 180-minute budget). For large PRs, retry with a longer timeout by commenting: @qwen-code /review --timeout=240. See workflow logs.

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [review-pr] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [review-pr] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@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.8-max-preview via Qwen Code /review

Comment on lines +417 to +421
this.fail(
runId,
`Failed to resolve workflow approval: ${approvalId}`,
Date.now(),
);

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] The resolvePendingApproval error path — where runtime.respond() throws, triggering this.fail() and handle abort — has no test coverage. — Concrete cost: if a future change breaks this multi-step recovery (fail + sibling drain + abort), no test would catch the regression.

Suggested change
this.fail(
runId,
`Failed to resolve workflow approval: ${approvalId}`,
Date.now(),
);
// Add a test in workflow-run-registry.test.ts:
// it('fails the run and drains siblings when respond throws', async () => {
// const r = new WorkflowRunRegistry();
// r.register({ runId: 'r1', ... });
// const respond = vi.fn(async () => { throw new Error('boom'); });
// // park approval with respond, then resolve → assert run failed, siblings rejected
// });
中文说明

resolvePendingApproval 的异常路径(runtime.respond() 抛出异常时触发 this.fail() 和 handle abort)目前没有测试覆盖。如果未来的修改破坏了这套多步恢复逻辑(fail + 排空兄弟审批 + abort),没有测试能捕获回归。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +452 to +457
const confirmationPayload = allowed
? this.buildAllowConfirmationPayload(
approval.name,
payload['updatedInput'],
)
: undefined;

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] handleWorkflowApproval discards the host's deny message (payload['message']), unlike the adjacent handleTeammateApproval which forwards it as { cancelMessage }. — Concrete cost: the workflow subagent's model sees only the generic 'User did not allow tool call' instead of the host's specific denial reason, making it more likely to retry the same denied command.

Suggested change
const confirmationPayload = allowed
? this.buildAllowConfirmationPayload(
approval.name,
payload['updatedInput'],
)
: undefined;
const confirmationPayload = allowed
? this.buildAllowConfirmationPayload(
approval.name,
payload['updatedInput'],
)
: typeof payload['message'] === 'string'
? ({ cancelMessage: payload['message'] } as ToolConfirmationPayload)
: undefined;
中文说明

handleWorkflowApproval 在拒绝时丢弃了 Host 的拒绝消息(payload['message']),而相邻的 handleTeammateApproval 会将其作为 { cancelMessage } 转发。这导致 Workflow 子 Agent 的模型只能看到通用的 'User did not allow tool call',而非 Host 给出的具体拒绝原因,增加了模型重试同一被拒命令的可能性。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +505 to +508
expect(workflowReg.setApprovalChangeCallback.mock.calls).toEqual([
[expect.any(Function)],
[undefined],
]);

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] The unmount test asserts workflowReg.setApprovalChangeCallback cleanup but omits the matching workflowReg.setStatusChangeCallback assertion. — Concrete cost: if a future refactor drops the setStatusChangeCallback(undefined) cleanup, neither test would fail, leaking a stale callback into an unmounted component.

Suggested change
expect(workflowReg.setApprovalChangeCallback.mock.calls).toEqual([
[expect.any(Function)],
[undefined],
]);
expect(workflowReg.setApprovalChangeCallback.mock.calls).toEqual([
[expect.any(Function)],
[undefined],
]);
expect(workflowReg.setStatusChangeCallback.mock.calls).toEqual([
[expect.any(Function)],
[undefined],
]);
中文说明

卸载测试断言了 workflowReg.setApprovalChangeCallback 的清理,但遗漏了对应的 workflowReg.setStatusChangeCallback 断言。如果未来的重构意外删除了 setStatusChangeCallback(undefined) 清理调用,两个测试都不会失败,从而泄漏一个过时的回调到已卸载的组件中。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +456 to +458
r.setApprovalRequestCallback(async () => {
throw new Error('host disconnected');
});

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] The synchronous-throw path in parkPendingApproval (source lines 532–541) has no test coverage; this test only exercises the async-rejection path (async () => { throw ... } always produces a rejected promise). — Concrete cost: if a future change breaks the sync-throw cleanup (runtime deletion, controller abort, direct filter), no test would catch it.

Suggested change
r.setApprovalRequestCallback(async () => {
throw new Error('host disconnected');
});
r.setApprovalRequestCallback(async () => {
throw new Error('host disconnected');
});
// Also add a sync-throw variant:
// r.setApprovalRequestCallback(() => { throw new Error('sync failure'); });
中文说明

parkPendingApproval 的同步抛出路径(源码 532–541 行)没有测试覆盖;当前测试仅验证了异步拒绝路径(async () => { throw ... } 总是产生 rejected promise)。如果未来的修改破坏了同步抛出的清理逻辑(runtime 删除、controller abort、直接 filter),没有测试能捕获。

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

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

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

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


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

@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 轮)。改动内容与我反驳保留之处如下:

Summary

Addressed all four automated-reviewer suggestions on PR #8240. No base-conflict merge was required (--conflict false), so origin/main was not merged.

Feedback points

  • [rc:3693160931] [Suggestion] handleWorkflowApproval discards the host's deny message — Implemented. permissionController.ts now forwards payload['message'] as { cancelMessage } on deny, mirroring the adjacent handleTeammateApproval. The payload flows through resolvePendingApprovalevent.respond, the same mechanism the teammate path already uses, so the workflow subagent's model sees the host's specific denial reason instead of the generic cancel. Added a focused test asserting the forwarded { cancelMessage }.
  • [rc:3693160935] [Suggestion] unmount test omits the workflowReg.setStatusChangeCallback assertion — Implemented. Verified the hook actually subscribes and cleans that callback (useBackgroundTaskView.ts mount/cleanup), then added the matching setStatusChangeCallback assertion to the unmount test so a future refactor dropping the cleanup is caught.
  • [rc:3693160918] [Suggestion] resolvePendingApproval error path has no test — Implemented. Added a test where runtime.respond() throws: asserts resolvePendingApproval returns false, the run is marked failed with the approval id in the error, the still-pending sibling approval is drained (responded Cancel), and the run's abort controller is aborted via the handle fallback.
  • [rc:3693160941] [Suggestion] synchronous-throw path in parkPendingApproval has no test — Implemented. Added a sync-throw variant (a non-async callback that throws) distinct from the existing async-rejection test; asserts the approval is cleared and cancelled through the bridge's rejectResponder without going through resolvePendingApproval.

Failed "Test" check

Investigated the red Test (ubuntu-latest Node 22.x) check. All PR-touched unit tests pass locally, and the build, typecheck, lint, Prettier, i18n, settings-schema freshness, and VS Code companion notices freshness guards all pass.

The only failure reproducible in this sandbox is the integration test qwen-serve-streaming.test.ts > Todo Stop Guard replay (expects 4 marker requests within a 30s poll, observes 2). I reproduced this identically on origin/main via a temporary detached checkout (then restored the branch and my stashed edits), which proves it is environmental to this sandbox — the fake OpenAI server cannot drive the three bounded guard replays within the timeout here — and not a regression from this PR. A set of home-directory-dependent unit tests (settings/config/subagent-manager/skill-manager/qwenOAuth2 path resolution) also fail in this sandbox for the same environmental reason (the real HOME leaks past the tests' homedir mock) and are unrelated to this PR's workflow-approval changes.

No code change was made for the Test check; per the workflow design its independent CI remains the final verification gate.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • prettier --check (4 changed files) — passed
  • vitest packages/core src/agents/workflow-run-registry.test.ts (touched) — 50 passed
  • vitest packages/cli src/nonInteractive/control/controllers/permissionController.test.ts src/ui/hooks/useBackgroundTaskView.test.ts (touched) — 40 passed
  • vitest integration-tests cli/qwen-serve-streaming.test.ts — 1 failed, reproduces identically on origin/main (environmental, not PR-caused)
中文说明

概要

已处理 PR #8240 上自动审查器提出的全部四条建议。本次无需解决与 base 的冲突(--conflict false),因此未合并 origin/main

反馈点

  • [rc:3693160931] [建议] handleWorkflowApproval 丢弃了 Host 的拒绝消息 — 已实现。permissionController.ts 现在会在拒绝时将 payload['message'] 作为 { cancelMessage } 转发,与相邻的 handleTeammateApproval 保持一致。该 payload 经由 resolvePendingApprovalevent.respond 传递,与 teammate 路径已使用的机制相同,因此 Workflow 子 Agent 的模型会看到 Host 给出的具体拒绝原因,而非通用的取消信息。已新增一个聚焦测试断言转发的 { cancelMessage }
  • [rc:3693160935] [建议] 卸载测试遗漏了 workflowReg.setStatusChangeCallback 断言 — 已实现。先确认 Hook 确实订阅并清理了该回调(useBackgroundTaskView.ts 的挂载/清理逻辑),然后在卸载测试中补上对应的 setStatusChangeCallback 断言,以便未来重构若删除该清理调用时能被捕获。
  • [rc:3693160918] [建议] resolvePendingApproval 的异常路径没有测试 — 已实现。新增一个 runtime.respond() 抛出异常的测试:断言 resolvePendingApproval 返回 false、Run 被标记为 failed 且 error 中包含该 approval id、仍处于 pending 的兄弟审批被排空(响应 Cancel)、以及 Run 的 abort controller 经由 handle 回退被 abort。
  • [rc:3693160941] [建议] parkPendingApproval 的同步抛出路径没有测试 — 已实现。新增一个同步抛出变体(一个非 async 的直接抛出回调),与现有的异步拒绝测试区分开;断言该审批被清除,并通过 bridge 的 rejectResponder 取消,而不经过 resolvePendingApproval

失败的 “Test” 检查

已排查红色的 Test (ubuntu-latest Node 22.x) 检查。所有本 PR 触及的单元测试在本地均通过,且 buildtypechecklint、Prettier、i18n、settings-schema 新鲜度、VS Code companion notices 新鲜度等守卫检查全部通过。

在本沙箱中唯一可复现的失败是集成测试 qwen-serve-streaming.test.ts > Todo Stop Guard replay(期望在 30 秒轮询内出现 4 个标记请求,实际观察到 2 个)。我通过临时 detached checkout 在 origin/main 上完全一致地复现了该失败(随后恢复了分支与我暂存的改动),这证明它是本沙箱的环境问题——此处的 fake OpenAI server 无法在超时内驱动三次有界的 guard 重放——而本 PR 引入的回归。一组依赖 home 目录的单元测试(settings/config/subagent-manager/skill-manager/qwenOAuth2 的路径解析)在本沙箱中也因同样的环境原因失败(真实 HOME 泄漏到了测试的 homedir mock 之外),与本 PR 的 workflow-approval 改动无关。

未针对 Test 检查做任何代码修改;按照工作流设计,其独立的 CI 仍是最终验证关口。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • prettier --check(4 个改动文件)— 通过
  • vitest packages/core src/agents/workflow-run-registry.test.ts(触及)— 50 通过
  • vitest packages/cli src/nonInteractive/control/controllers/permissionController.test.ts src/ui/hooks/useBackgroundTaskView.test.ts(触及)— 40 通过
  • vitest integration-tests cli/qwen-serve-streaming.test.ts — 1 失败,在 origin/main 上完全一致复现(环境问题,非 PR 导致)

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: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

中文说明

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

— qwen3.8-max-preview 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 triage — no action this round

No actionable feedback was found on this PR, so no code change was made.

What was triaged

  • Reviews: one [COMMENTED] review from the automated reviewer (qwen-code-ci-bot). It is a coverage disclaimer stating that the build-and-test — Integration Tests (CLI, No Sandbox) suite was skipped in CI and was not run locally during the review. It is not a CHANGES_REQUESTED review, names no defect, requests no change, and carries no **[Suggestion]** finding.
  • Inline comments: none.
  • Issue-level comments: none.
  • Failed checks: none.
  • Still-red checks: none.

Why no action

The single review comment is informational about the reviewer's own coverage, not a finding about the code. Verifying against the raw check data confirms every check on the PR is either SUCCESS or SKIPPED — there are no failing or cancelled checks. The Integration Tests (CLI, No Sandbox) job is SKIPPED (a normal gated outcome), not failed, so it provides no defect signal to act on. With no named defect, no change request, no inline or issue feedback, and a green check set, there is nothing in scope to fix or respond to in code.

If a future round surfaces a concrete integration-test failure or a specific finding, it will be addressed then.

中文说明

Autofix 审查分诊 —— 本轮无需处理

本 PR 未发现可处理的反馈,因此未做任何代码改动。

分诊内容

  • 审查(Reviews): 来自自动审查机器人(qwen-code-ci-bot)的一条 [COMMENTED] 审查。它是一条覆盖范围免责声明,说明 build-and-test — Integration Tests (CLI, No Sandbox) 套件在 CI 中被跳过,且审查时未在本地运行。它不是 CHANGES_REQUESTED(要求修改)审查,未指出任何缺陷,未提出任何修改要求,也不包含任何 **[Suggestion]** 发现。
  • 行内评论(Inline comments): 无。
  • Issue 级评论(Issue-level comments): 无。
  • 失败的检查(Failed checks): 无。
  • 持续失败的检查(Still-red checks): 无。

为何不处理

这条唯一的审查评论只是关于审查者自身覆盖范围的信息性说明,并非针对代码的发现。对照原始检查数据核实后确认:本 PR 上的每一项检查要么是 SUCCESS(成功),要么是 SKIPPED(跳过)——没有任何失败或取消的检查。Integration Tests (CLI, No Sandbox) 作业是 SKIPPED(正常的门控结果),而非失败,因此没有可供处理的缺陷信号。在没有指出缺陷、没有修改要求、没有行内或 issue 反馈、且检查集合全绿的情况下,本轮没有任何可修复或需在代码中回应的事项。

如果后续某一轮出现具体的集成测试失败或明确的发现,届时再予以处理。

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


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

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Overview

Completes the foreground Dynamic Workflow permission chain. A workflow subagent's TOOL_WAITING_APPROVAL is now parked on its owning WorkflowTask (WorkflowRunRegistry.bridgeApprovalEventsparkPendingApproval, wired in from workflow-runner.ts via createProductionDispatch's new bridgeApprovalEvents hook), and answered through exactly one of three parent transports: the TUI background dialog, Session.#requestWorkflowApproval (ACP), or PermissionController.handleWorkflowApproval (stream-json can_use_tool). Every path is one-shot only.

I checked out 7a2ffabc71 and ran the affected suites: core 185/185 (workflow-run-registry, workflow-snapshot, workflow-runner, workflow-orchestrator) and cli 589/589 (Session, permissionController, useBackgroundTaskView, BackgroundTasksDialog, BackgroundTasksPill). Green.

The security posture is the strongest part of this PR, and it holds up under tracing:

  • normalizeWorkflowApprovalOutcome (workflow-run-registry.ts:850-ish region) coerces anything that isn't ProceedOnce to Cancel inside the registry, so every transport inherits the one-shot rule — a host can't smuggle a persistent grant through any of the three channels.
  • toPermissionOptions(details, /* forceHideAlwaysAllow */ true) plus resolvePermissionOutcome's unoffered-option throw means an ACP host answering proceed_always_project lands in the catch arm and denies. buildPermissionSuggestions likewise only emits allow/deny/modify, so the stream-json path can't suggest a persistent rule either.
  • restrictWorkflowConfirmationDetails is a real whitelist with an exhaustive never arm, nulls originalContent/newContent, and refuses plan / ask_user_question outright.
  • Every unknown or absent channel denies rather than waits: parkPendingApproval returns 'rejected' when neither callback is registered, and nonInteractiveCli.ts only registers the channel when options.controlService exists — so plain headless denies instead of hanging.
  • respond is idempotent per callId in agent-core.ts (the responded Set), which is what makes the several double-settle races benign — registry aborts requestController → the ACP catch arm calls resolvePendingApproval again → approval already gone → false, no second answer.
  • pendingApprovals genuinely stays process-local. git grep getWorkflowRunRegistry() shows the only consumers are useBackgroundTaskView, BackgroundTasksDialog, workflowsCommand, backgroundWorkUtils, goalHook — nothing in serve/daemon serializes a WorkflowTask — and toSnapshot omits the field (pinned by the new sentinel test).

Findings below, most severe first.


1. (High) The new connection-wide permission queue can be held open by a request nobody will answer

Session.ts:285 introduces a module-level WeakMap<AgentSideConnection, Promise<void>>, and #requestPermissionQueued (Session.ts:6378) chains every permission request on that connection through it — not just workflow approvals. Session.ts:7473 routes SubAgentTracker through it and Session.ts:7971 routes the primary tool path through it, and the key is the connection, so sessions sharing one connection are serialized too (your own test serializes permissions across sessions sharing one ACP connection pins exactly that).

The problem is what happens on abort. The caller's promise is raced against the signal, but the tail deliberately waits for the raw transport promise:

const transportRequest = prior.then(() =>
  signal.aborted
    ? requestPermissionWithAbort(this.client, params, signal)
    : this.client.requestPermission(params),
);
const tail = transportRequest.then(() => undefined, () => undefined);

Nothing cancels the outstanding session/request_permission RPC on the client side. So when a workflow approval is aborted from the agent side — session dispose (Session.ts:2107), registry.cancel()/abortAll()/reset(), or resolvePendingApproval arriving from another route — the caller unwinds and denies correctly, but the RPC stays open and every subsequent permission request on that connection is queued behind it and never sent. Tools behind it just sit there with no prompt anywhere. Your test waits for an aborted ACP transport request to settle before advancing the queue asserts this as intended (toHaveBeenCalledOnce() after firstAbort.abort()), so it's a design choice rather than an oversight — but the blast radius is wider than workflow approvals.

How bad it gets depends entirely on the host answering an orphaned prompt:

  • Bundled IDE host — fixed here: WebViewProvider.ts now resolves the pending permission to 'cancel' when a matching tool_call_update goes terminal, and #finishWorkflowApprovalToolCall sends exactly that. Good.
  • Daemon / web-shell — bounded at 5 minutes by permissionResponseTimeoutMs. Note the comment on that option in bridgeOptions.ts is describing this exact failure mode ("the per-session FIFO can drain instead of poisoning forever"); the bridge already learned this lesson one layer up.
  • Third-party ACP clients — unbounded. A client that dismisses the prompt on the terminal tool_call_update without answering the RPC (a reasonable reading — the tool call just failed) leaves the connection's permission channel wedged for the process lifetime.

Suggest bounding the tail rather than relying on host behavior: race transportRequest against a timeout, or let the tail advance when the request's own signal aborts (accepting a possible transient double-prompt) — the outcome is already normalized to Cancel either way, so an extra prompt is far cheaper than a silent stall on every other session.

2. (Medium) Fail-closed denials are completely silent

parkPendingApproval (workflow-run-registry.ts:456) auto-denies the child tool with no log line and no UI signal in four cases: entry not running / no callbacks registered (:466), more than MAX_PENDING_WORKFLOW_APPROVALS = 32 parked (:478), a plan / ask_user_question confirmation, and a restricted payload over MAX_WORKFLOW_APPROVAL_DISPLAY_CHARS = 64 KB (:490). The behavior is correct and well tested (rejects unsupported and oversized approval details, bounds pending approvals per workflow run, fails closed immediately when no host approval channel exists) — the gap is observability.

The 64 KB cap is the one users will actually hit: a workflow agent editing a large generated file produces a fileDiff well past that, and all the user sees is an agent reporting the user denied a tool they were never shown. Every other reject path in this file logs via debugLogger; these three don't. At minimum a debugLogger.warn with the reason, and ideally a reason carried on the deny so the agent's transcript says "suppressed: diff too large" rather than "denied".

3. (Medium) The synthetic parent tool call reports approval outcome as execution outcome

#finishWorkflowApprovalToolCall (Session.ts:1478) marks the approval's toolCallId completed when resolved && outcome === ProceedOnce. But that only means the user allowed it — the child tool then runs inside the workflow subagent and may fail. The parent ACP transcript will show Shell: rm -rf build as completed for a command that exited 1. Worth either leaving it pending/in_progress and letting the workflow row carry the real outcome, or at least distinguishing it in _meta beyond workflowApproval: true so hosts don't render it as a real execution result.

4. (Low) Per-agent MCP tools degrade in the ACP card

resolveToolMetadata(approval.name, rawArgs) resolves against the parent session's ToolRegistry. A workflow agent on the override path (agent({agentType}) with per-agent MCP servers) gets its own registry, so its MCP tool won't be found upstream: title falls back to the bare tool name, locations: [], kind: 'other'. The mcp confirmation details still carry serverName/toolName so the user isn't blind, but the card is noticeably weaker than for built-ins. Not blocking; worth a comment noting the limitation.

5. Nits

  • #requestPermissionQueued calls requestPermissionWithAbort({ requestPermission: () => transportRequest }, params, signal) — a synthetic client purely to reuse the abort race, with params passed and unused. A named raceAbort(promise, signal) helper would read straighter and make the intent (the tail is deliberately not raced) obvious at the call site.
  • The content construction for edit in #requestWorkflowApproval (Session.ts:1405-ish) hand-rolls the warnings loop that buildPermissionRequestContent already does, purely to swap the type: 'diff' block for text. Since the restricted details always null out originalContent/newContent, a small shared helper (or a renderDiffAsText flag on buildPermissionRequestContent) would avoid the two copies drifting.
  • handleWorkflowApproval (permissionController.ts:425-ish) is missing the two defensive guards its sibling handleTeammateApproval has: the early abortSignal.aborted check and the getInputFormat() === STREAM_JSON assertion. Both are satisfied by the current wiring (sendControlRequest rejects on a pre-aborted signal, and only controlService registers the channel), so this is symmetry rather than a bug. Also, AbortSignal.any([...]) sits outside the try, so if it ever threw, the deny would come from parkPendingApproval's .catch() rather than from this handler — still fail-closed, but the contract is easier to read if it's local.
  • PermissionDrawer.tsx's contentText memo now scans toolCall.content for every kind and joins with \n\n, then throws the result away unless the kind is switch_mode / edit / write. Harmless, just slightly wasteful.

Test coverage

Genuinely good, and unusually specific — cross-session serialization, abort-before-settle queue ordering, host timeout, session dispose, core-side clear, the restricted edit diff rendering as text rather than a diff block, the unoffered-persistent-option rejection, registry reset racing session switching, snapshot sentinel omission, IDE-host child/parent isolation.

The one gap that matters is finding 1: there's a test proving the queue waits for an abandoned request, but none for what happens when nobody ever settles it. If you bound the tail, that's the test to add.

Verdict

The core state machine and the permission-narrowing logic are sound, and I couldn't find a path that auto-approves or leaks a persistent grant. Finding 1 is the one I'd want addressed before merge — not because the workflow path is wrong, but because it hands a connection-wide failure mode to the primary tool path, which previously had none.

@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

Finding 1 (High): Permission queue wedge on aborted RPC — Fixed

The connection-wide permission queue tail now advances when the caller's signal aborts, rather than waiting indefinitely for the orphaned RPC to settle. Implemented via Promise.race between the transport settling and the signal's abort event. This accepts a possible transient double-prompt (the outcome is already normalized to Cancel) in exchange for never wedging the primary tool path.

Changed: Session.ts (#requestPermissionQueued), Session.test.ts (test renamed and updated to assert the queue advances immediately on abort).

Finding 2 (Medium): Silent fail-closed denials — Fixed

Added debugLogger.warn at all three previously silent reject paths in parkPendingApproval: entry missing/not running/no host channel, pending limit reached, and unsupported type or oversized payload. Each message includes the runId, callId, and specific reason.

Changed: workflow-run-registry.ts.

Finding 3 (Medium): Approval outcome reported as execution outcome — Fixed

Added approvalOutcome: 'approved' | 'denied' to the synthetic tool call's _meta in #finishWorkflowApprovalToolCall, so hosts can distinguish an approval interaction from a real execution result.

Changed: Session.ts.

Finding 4 (Low): Per-agent MCP tools degrade in ACP card — Noted

Added a comment at the resolveToolMetadata call documenting that it resolves against the parent session's registry and per-agent MCP tools fall back to the bare tool name.

Changed: Session.ts.

Nit 5a: Synthetic client in #requestPermissionQueuedSuperseded

The code was reworked for Finding 1; the synthetic client pattern remains but the tail behavior is now explicit via the Promise.race + comment.

Nit 5b: Duplicated edit content construction — Declined

Two call sites with different rendering (diff block vs. text) — extracting a shared helper for two uses is premature abstraction per AGENTS.md Simplicity First.

Nit 5c: Missing defensive guards in handleWorkflowApprovalFixed

Added the early approvalSignal.aborted check with resolvePendingApproval(Cancel) return, matching handleTeammateApproval's pattern. The getInputFormat() assertion was skipped since the wiring already guarantees stream-json mode.

Changed: permissionController.ts.

Nit 5d: contentText memo scans all kinds — Declined

Harmless per the reviewer; the memo is cheap and the current structure is clean. Not worth the diff growth.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • vitest packages/core workflow-run-registry.test.ts — 50 passed
  • vitest packages/core workflow-runner.test.ts, workflow-orchestrator.test.ts, workflow-snapshot.test.ts — 135 passed
  • vitest packages/cli Session.test.ts — 474 passed
  • vitest packages/cli permissionController.test.ts — 23 passed
  • vitest packages/cli useBackgroundTaskView.test.ts, BackgroundTasksDialog.test.tsx, BackgroundTasksPill.test.tsx, nonInteractiveCli.test.ts — 185 passed, 1 skipped
  • vitest packages/vscode-ide-companion WebViewProvider.test.ts — 51 passed
  • vitest packages/webui PermissionDrawer.test.tsx — 3 passed
中文说明

已处理的审查反馈

发现 1(高):中止 RPC 导致权限队列阻塞 — 已修复

连接级权限队列的尾部现在会在调用方的 signal 中止时立即推进,而不是无限等待孤立的 RPC 完成。通过 Promise.race 在传输完成和 signal 的 abort 事件之间竞争实现。这接受了可能的瞬态重复提示(结果已被规范化为 Cancel),以换取主工具路径永远不会被阻塞。

修改文件: Session.ts#requestPermissionQueued)、Session.test.ts(测试重命名并更新为断言队列在中止时立即推进)。

发现 2(中):静默的失败关闭拒绝 — 已修复

parkPendingApproval 中三个之前静默的拒绝路径添加了 debugLogger.warn:条目不存在/未运行/无宿主通道、达到待处理上限、以及不支持的类型或超大负载。每条消息包含 runIdcallId 和具体原因。

修改文件: workflow-run-registry.ts

发现 3(中):审批结果被报告为执行结果 — 已修复

#finishWorkflowApprovalToolCall 的合成工具调用的 _meta 中添加了 approvalOutcome: 'approved' | 'denied',以便宿主可以区分审批交互和真实执行结果。

修改文件: Session.ts

发现 4(低):每个代理的 MCP 工具在 ACP 卡片中降级 — 已标注

resolveToolMetadata 调用处添加了注释,说明它针对父会话的注册表解析,每个代理的 MCP 工具会回退到裸工具名。

修改文件: Session.ts

细节 5a:#requestPermissionQueued 中的合成客户端 — 已被取代

代码因发现 1 而重构;合成客户端模式保留,但尾部行为现在通过 Promise.race + 注释明确表达。

细节 5b:重复的编辑内容构造 — 拒绝

两个调用点有不同的渲染方式(diff 块 vs 文本)——为两个使用点提取共享辅助函数属于过早抽象,不符合 AGENTS.md 的简洁优先原则。

细节 5c:handleWorkflowApproval 缺少防御性守卫 — 已修复

添加了早期的 approvalSignal.aborted 检查并返回 resolvePendingApproval(Cancel),与 handleTeammateApproval 的模式一致。跳过了 getInputFormat() 断言,因为接线已保证 stream-json 模式。

修改文件: permissionController.ts

细节 5d:contentText memo 扫描所有类型 — 拒绝

审查者认为无害;memo 开销很小,当前结构清晰。不值得增加 diff。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • vitest packages/core workflow-run-registry.test.ts — 50 通过
  • vitest packages/core workflow-runner.test.ts, workflow-orchestrator.test.ts, workflow-snapshot.test.ts — 135 通过
  • vitest packages/cli Session.test.ts — 474 通过
  • vitest packages/cli permissionController.test.ts — 23 通过
  • vitest packages/cli useBackgroundTaskView.test.ts, BackgroundTasksDialog.test.tsx, BackgroundTasksPill.test.tsx, nonInteractiveCli.test.ts — 185 通过,1 跳过
  • vitest packages/vscode-ide-companion WebViewProvider.test.ts — 51 通过
  • vitest packages/webui PermissionDrawer.test.tsx — 3 通过

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

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Review: feat(workflows): bubble workflow agent approvals

Overview

Wires the missing parent→child permission path for foreground Workflow agents. WorkflowRunRegistry gains a parked-approval queue (bridgeApprovalEvents / parkPendingApproval / resolvePendingApproval), the orchestrator installs the bridge per dispatch attempt, and three parent transports consume it: the Ink dialog (via setApprovalChangeCallback), ACP (Session.#requestWorkflowApproval + a connection-level prompt queue), and stream-json (PermissionController.handleWorkflowApproval). Plain headless registers nothing and therefore denies.

The core design is sound and the fail-closed posture is real, not just claimed — I traced every rejection path.

What's good

  • respond lives in a process-local approvalRuntimes map, not on the public WorkflowApproval DTO. This is a genuine improvement over BackgroundApproval, which embeds respond on the entry (background-tasks.ts:230); snapshots and UI code now structurally cannot serialize or invoke a runtime handle.
  • Every terminal path drains: complete/fail/cancel/abortAll/clear all call rejectPendingApprovals before flipping status, and parkPendingApproval refuses when status !== 'running'. normalizeWorkflowApprovalOutcome collapses every persistent outcome to Cancel, backed by hideAlwaysAllow: true in the restricted details and forceHideAlwaysAllow at the ACP option builder — belt and braces.
  • The 'duplicate' vs 'rejected' distinction fixes a bug class the background-agent bridge still has. In background-tasks.ts:1160, addPendingApproval returns false for both "not running" and "duplicate callId", and the caller then cancels — so a re-emitted TOOL_WAITING_APPROVAL for a still-parked call (the emitter at agent-core.ts:1804 sits in a per-tick scan loop, so re-emission is reachable) kills a live prompt. Worth back-porting the fix.
  • Test coverage is strong: 18 registry cases including the bound, the sync/async host-channel failures, the respond-throws path, and a registry reset racing session switching; the ACP suite covers cross-session ordering and queue-advance-on-abort.

Issues

1. Abort-listener leak on the shared turn signal — Session.ts:6381 (medium)

new Promise<void>((resolve) => {
  if (signal.aborted) return resolve();
  signal.addEventListener('abort', () => resolve(), { once: true });
}),

{ once: true } only removes the listener if abort fires. On the happy path it stays for the life of the signal. In runTool (Session.ts:7269) activeToolAbortSignal starts as the prompt-turn signal shared by every tool call in the turn, so a turn with >10 confirmations trips Node's MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 abort listeners added to [AbortSignal] — written to stderr, which in ACP/stdio mode is host-visible noise. Hold the listener in a variable and removeEventListener it from a finally on transportRequest.

2. Cross-session head-of-line blocking (medium, design)

permissionRequestTails is keyed on the shared AgentSideConnection, so a prompt the host never answers in session A blocks every other session's prompt until A's signal aborts. And once it does abort, the queue advances while the orphaned RPC is still outstanding — so the serialization invariant is best-effort by construction (the comment says as much).

The tradeoff is deliberate, but is cross-session scope actually required? If a host renders one prompt per session, a per-session tail removes the starvation risk without losing anything. If it is required, consider a bounded wait so a wedged host can't starve unrelated sessions indefinitely.

3. Oversized approvals are denied rather than truncated — workflow-run-registry.ts:490 (medium)

A workflow agent making a legitimately large edit (fileDiff > 64 KB) has the entire approval rejected, and the user sees nothing — the only trace is a debugLogger.warn. Truncating fileDiff for display with an explicit … diff truncated, N bytes omitted marker keeps those edits approvable while still bounding display state. If denial really is intended, it should be user-visible rather than debug-only.

4. Programmatic denials are indistinguishable from user denials to the agent — workflow-run-registry.ts:834 (medium)

rejectResponder always responds Cancel with no payload. So no host channel, pending limit reached, payload too large, and unsupported type (plan / ask_user_question:903) all arrive at the workflow agent as a bare user rejection, and the model will likely retry the identical operation. The stream-json path already carries a reason ({ cancelMessage }, permissionController.ts:456); threading rejectResponder(respond, reason) would make these self-explanatory in the child transcript.

Related: the warn at :497 conflates "unsupported type" and "exceeds N chars" into one message — split them so the log is actionable.

5. Missing stream-json guard — permissionController.ts:425 (low)

handleWorkflowApproval sends can_use_tool without the inputFormat !== InputFormat.STREAM_JSON check its sibling handleTeammateApproval performs at :337. Registration is gated on options.controlService so it shouldn't be reachable today, but the sibling's defensive check exists for exactly this reason. (The added sdkCanUseToolTimeoutMs here is a good divergence — handleTeammateApproval passes undefined and can wait forever.)

6. No run identity in the stream-json request — permissionController.ts:446 (low)

tool_use_id: approval.approvalId is wfap_N, a registry-global counter with no runId/subagentId, and nothing marks the request as a workflow-child approval — unlike the ACP path, which carries _meta.workflowApproval. An SDK policy host that wants to scope rules to workflow children, or attribute a decision back to a run, has nothing to key on.

7. Synthetic tool call reports completed at approval time (low)

#finishWorkflowApprovalToolCall sends status: 'completed' as soon as the user allows, before the child operation runs. _meta.approvalOutcome disambiguates, but a host reading status alone shows "completed" for an operation that may subsequently fail.

8. The webui change is broader than the PR describes (low)

PermissionDrawer's editReviewText applies to every edit/write prompt, not just workflow approvals. Normal edits carry their diff as { type: 'diff' } (permissionUtils.ts:130) so the diff itself is unaffected — but autoModeFallback messages and edit warnings, previously not rendered in the drawer, now appear in a new <pre>. Probably an improvement; it's still an unannounced change to the primary permission UI, and the only new test covers the workflow shape. Worth a regression test asserting a normal type: 'diff' edit prompt is unchanged.

Minor

  • bridgeApprovalEvents's seenSources (:359) is the one path in the bridge that neither parks nor rejects. It's correct — a re-emission after clearPendingApproval refers to an already-settled call, and respond is idempotent via the runtime's responded set — but that reasoning isn't obvious from the code and deserves a comment, since it reads as a hole in the fail-closed contract.
  • ACP edit approvals send the diff as type: 'content' text rather than type: 'diff' (necessary, since originalContent/newContent are stripped). Hosts with native diff rendering will show plain text for workflow approvals only. Fine for this phase; worth a note in the follow-up if structured diffs matter.

Verdict

No correctness blockers found — the fail-closed contract holds on every path I traced, and the state restriction (raw args and responders kept process-local, approvals omitted from snapshots) is done properly. Items 1–4 are worth addressing before merge; 1 is a concrete stderr regression and 3–4 will silently confuse both the user and the child model in real use.

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

Scripted assertions: 25 passed · 0 failed · 25 total

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

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

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

Verification report (report.md)

# PR 8240 — Deep Verification: `feat(workflows): bubble workflow agent approvals`

**Verdict: `merge-ready`** — scripted assertions **25 pass / 0 fail / 25 total**; targeted gates **936 passed, 1 pre-existing skip, 0 fail**; no blocking finding.
Verified head: `53553912dd53c0ae9e021aa8825f2aff28a24fe6` (`git rev-parse HEAD^2`); base tip `HEAD^1 = 4dc50b18e`. CI merge-ref checkout (shallow, depth 2). First round (no `previous-report.md`).

<details>
<summary>中文摘要</summary>

- **结论**:`merge-ready`。自研 mock-free 断言 25 通过 / 0 失败;定向门禁 936 通过、1 个既有 skip、0 失败;无阻塞性发现。
- **A/B 结论**:核心改动(`WorkflowRunRegistry` 审批机制)经 head 与 base 编译产物对照验证为**承重**。head 上 Workflow Agent 的审批请求被停放、上报父会话、且 `respond` 恰好结算一次(21/21);base 上 `bridgeApprovalEvents` 不存在,请求无人应答、`respond` 永不触发(即 PR 所述“无限阻塞”缺口,4/4 反向断言通过)。见 `01-ab-head-matrix.png` 与 `02-ab-base-gap.png`。
- **突变矩阵**:7 个新增守卫中 5 个被测试钉死(bounded/dedup-行为/no-channel/restrict-edit/normalize/display-cap);2 个存活者经 finer mutation 与活性探针裁定为**覆盖缺口**(行为正确且可达,但无测试断言),非死代码、非空测试,均非合并条件。见 `03-mutation-matrix.png`。
- **次级声明**:ACP 连接边界串行化的“中止不阻塞后续请求”竞态测试为承重(移除 abort-advance 后该测试转红);stream-json 的 fail-closed deny 路径为承重(反转 allow/deny 判定杀死 3 个测试,含 2 个 deny 路径)。
- **未覆盖**:真实 PTY/Ink 渲染、Windows/Linux 终端、后台执行/暂停恢复/持久恢复、持久授权、真实外部模型/网络、以及逐 commit 归因(shallow checkout 仅 3 个 commit 可达)均未覆盖。

</details>

## Scope selected

- **Central claim**: the `WorkflowRunRegistry` approval machinery — a workflow agent's `TOOL_WAITING_APPROVAL` is parked on its owning run, surfaced via a host callback, and its `respond` is settled **exactly once**; requests are bounded (cap 32), identity-deduped, restricted (edit content stripped), outcome-normalized (persistent → `Cancel`), and **fail-closed** on terminal/cancel/dispose/no-channel.
- **Secondary 1**: ACP `#requestPermissionQueued` serializes prompts per connection (WeakMap on the connection) and an aborted/orphaned RPC still advances the queue.
- **Secondary 2**: stream-json `handleWorkflowApproval` applies only an explicit one-shot allow (with sanitized `updatedInput`); timeout / deny / error all resolve `Cancel`.

Everything else (Ink rendering, IDE-host diff drawing, background/detach phases) is explicitly out of scope — see _Not covered_.

## Central claim — A/B load-bearing proof

The entire approval mechanism is new: the base registry (`git show HEAD^1:…/workflow-run-registry.ts`, 539 lines) has **zero** approval surface (`bridgeApprovalEvents` / `parkPendingApproval` / `resolvePendingApproval` / `setApprovalRequestCallback` / `pendingApprovals` all absent); head adds all of it (912 lines, +372). So the change under test is the whole mechanism, and the control differs by nothing else.

Harness `registry-ab.mjs` drives the **real compiled** `WorkflowRunRegistry` through the **real** `AgentEventEmitter` — the exact production entry point (`workflow-runner.ts` → `registry.bridgeApprovalEvents(runId, emitter)`). The only fakes are the agent's `respond` callback and the parent host-channel callback, the two seams production wires up. No stub of the code under test.

| cell | build | how produced | observable oracle | result |
| --- | --- | --- | --- | --- |
| head | `53553912d` | prebuilt `packages/core/dist` | approval parked; surfaced to host once; `respond` called **exactly once** with the parent decision; + 17 guard assertions | **21/21 pass** (`01-ab-head-matrix.png`) |
| base (control) | `HEAD^1` recompiled with `tsc` (25 s) | `bridgeApprovalEvents` undefined | nothing parked; host never invoked; **`respond` NEVER invoked** → the workflow would block forever | **4/4 pass** (encoded control red; `02-ab-base-gap.png`) |

The pair is the load-bearing verdict: on head the parent's decision reaches the agent (`respond` once, `proceed_once`); on base there is no route to answer (`respond` count `0`), which is precisely the indefinite-block gap the PR exists to close. The base capability probe is encoded as an expected absence so the control's red is counted as a **pass**, not an unexpected failure.

Head matrix (all 21 in `01-ab-head-matrix.png`): park-once / surface-once / respond-once; **settle-once** (2nd resolve returns `false`, `respond` still 1×); **fail-closed** via `cancel()` / `complete()` / `fail()` / no-host-channel (each rejects the parked approval with `Cancel`); **bounded** (33rd concurrent request rejected at cap 32, `pending` stays 32); **dedup** (same `subagentId+callId` coalesced to 1); **restrict** (edit DTO: `originalContent=null`, `newContent=''`, `hideAlwaysAllow=true`, diff kept); **normalize** (`ProceedAlways`→`Cancel`, payload dropped when normalized, preserved when `ProceedOnce`); **tool-result** clears the parked approval; **dispose** (`reset()` rejects all + aborts request controllers).

## Findings

Ordered by severity. **None are blocking**; both are completeness notes the author may want to track.

### S1 — Coverage gap: parking on a *settled* entry is unasserted (Suggestion)

The `entry.status !== 'running'` guard in `parkPendingApproval` is **live but not test-pinned**. Mutating it to allow parking on non-running entries left the 50-test registry suite green (survivor). A direct probe confirms the behavior is correct and reachable: registering a run, `complete()`-ing it, then emitting `TOOL_WAITING_APPROVAL` is rejected fail-closed (`pending=0`, `respond` called once with `cancel`).

```
STATUS-GUARD-LIVE= YES (rejects parking on settled entry, fail-closed)
```

So this is a coverage gap (behavior right, nothing asserts it), not dead code. A one-line test — emit an approval after `complete()`/`cancel()` and assert it is rejected with `Cancel` — would pin it.

### S2 — Coverage gap: the park-level dedup is a redundant 2nd layer (Suggestion)

`parkPendingApproval`'s `entry.pendingApprovals.some(same subagentId+callId) → 'duplicate'` guard survived mutation, but only because a **first** dedup layer (`seenSources` in `bridgeApprovalEvents`) masks it for same-bridge duplicates. Finer mutations adjudicate it:

| mutation | suite result | killed test |
| --- | --- | --- |
| disable `seenSources` (1st layer) | 49/50 | `does not re-park a duplicate event after it was resolved` |
| disable `seenSources`, keep park-level dedup | 49/50 | same test (park-level dedup alone does **not** catch a re-emit *after resolution*, since the approval was already removed from `pendingApprovals`) |

The dedup **behavior** is pinned (via `seenSources`). The park-level guard is genuine defense-in-depth for a *cross-bridge* collision on one run (each dispatch attempt builds its own bridge, and `seenSources` is per-bridge), but no test exercises that specific path. Not dead code, not a vacuous test — a coverage gap for the cross-bridge case.

## Secondary claims — verified

- **ACP serialization race (load-bearing).** `Session.test.ts` `advances the permission queue when a request is aborted without waiting for the orphaned RPC` (line 941) is the subtle one: the `Promise.race` with an abort listener in `#requestPermissionQueued` keeps an orphaned RPC from wedging later requests. Vacuity check: replacing `signal.addEventListener('abort', () => resolve(), …)` with a no-op made **exactly that test go red** (and only it). The claim holds and is pinned.
- **stream-json fail-closed (load-bearing).** Inverting the `allowed` computation in `handleWorkflowApproval` (`=== 'allow'` → `!== 'allow'`) killed **3** tests: `round-trips workflow approval … with updated input`, `cancels a workflow approval when the host denies it`, `forwards the host deny message to the workflow subagent`. A deny can never silently become an allow; the deny path is pinned.

## Targeted gates (affected workspaces)

| suite | result |
| --- | --- |
| core `workflow-run-registry` / `workflow-orchestrator` / `workflow-runner` / `workflow-snapshot` | **185 passed** (50 / 120 / 5 / 10) |
| cli `permissionController` + `nonInteractiveCli` | **116 passed, 1 skipped** (pre-existing) |
| cli `Session.test.ts` (ACP) | **474 passed** |
| cli UI `BackgroundTasksDialog` / `BackgroundTasksPill` / `useBackgroundTaskView` / `workflowsCommand` | **107 passed** |
| webui `PermissionDrawer` | **3 passed** |
| vscode-ide-companion `WebViewProvider` | **51 passed** |
| **total** | **936 passed, 1 pre-existing skip, 0 fail** |

Mutation matrix on the new guards (baseline 50/50 green; `03-mutation-matrix.png`): **5/7 pinned** (`bounded-cap`, `no-channel-failclosed`, `restrict-edit-content`, `normalize-outcome`, `display-char-cap` each kill their named test); 2 survivors adjudicated above as coverage gaps. No mutant regressed a killed test to survived; the unmutated control is green, so the kills mean something.

## Not covered

- **Real PTY / Ink rendering** of the approval card (the PR's macOS PTY evidence was not reproduced here; the harness verifies the registry/transport layer, not pixels). The Ink components' *logic* is covered by the 107 UI unit tests above.
- **Windows / Linux terminals** (PR marks these ⚠️ / CI-owned).
- **Background execution, pause/resume, durable recovery, persistent permission grants** — explicitly out of this phase per the PR.
- **Real external model / network behavior.**
- **Per-commit attribution**: the checkout is depth 2 (merge commit + base tip + PR head only); the 4 commits in `$QWEN_VERIFY_CONTEXT` are not individually reachable, so the aggregate `HEAD^1..HEAD` diff was verified and per-commit claims were not separated.
- **Root-wide lint/format/typecheck** were not re-run (the PR's own CI covers them; this round spent its budget on the A/B + mutation matrix). Head typecheck is established by the CI `npm run build` that produced the prebuilt `packages/core/dist` this round consumed; the base-control `tsc` build separately typechecked the base closure.

## Methodology

Ran inside the CI verify container (`node:22-bookworm`, the lane's own runtime). Head `packages/core/dist` was prebuilt; the base control was a scratch `git worktree` at `HEAD^1` with core recompiled via `tsc` (25 s) against the symlinked root `node_modules`. The PR leaves `package.json`/`package-lock.json` untouched and `@qwen-code/qwen-code-core` has no cross-workspace dependencies, so reusing the root install is a clean control; the harness additionally loads each compiled artifact **by absolute path** and the registry's compiled closure has zero `@qwen-code/*` imports (verified), so the head-pointing `node_modules/@qwen-code/qwen-code-core` symlink is never traversed. The harness drives the real registry through the real `AgentEventEmitter`; mutation/vacuity checks mutate source in place (vitest transpiles TS directly), run the named suite, and restore — `git status` confirmed a pristine tree afterward and the base worktree was removed. Raw logs: `head-harness.log`, `base-harness.log`; harnesses `registry-ab.mjs`, `mutate.mjs`, `mutate2.mjs`; evidence in `evidence/`.

Evidence images

01-ab-head-matrix

02-ab-base-gap

03-mutation-matrix

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has no review of its own on 53553912dd53c0ae9e021aa8825f2aff28a24fe6. If this re-run was meant to approve, it did not — an approval left by another account is a separate vote and does not count as the bot's own.

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

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

✅ Maintainer local verification of PR #8240 — re-run at head 5355391

Verdict: PASS. No blocking issue found; this looks ready to merge from a verification standpoint.

This supersedes my earlier verification, which was done at acc6694 — three commits have landed since (ba531d3 base merge, 7a2ffab deny-message forwarding, 5355391 queue-advance-on-abort + approval observability), so I rebuilt and re-verified from scratch. Everything below was produced locally against a freshly built production bundle of the current head, driven through a real macOS PTY and a real stream-json control channel with a deterministic local mock model. No live provider or network was involved.

Environment. Isolated git worktree pinned to 5355391 · npm run build && npm run bundledist/cli.js (--version = 0.21.2) · macOS (Darwin 25.6) · Node 22 · QWEN_CODE_ENABLE_WORKFLOWS=1 · --approval-mode default · workspace-scoped settings so only the child's request needs confirmation (the parent's own workflow call is pre-allowed).


1. Foreground TUI — the headline path

The mock makes the parent call workflow; the script dispatches one child agent, and that child asks for a Shell command (echo approve > proof.txt) that requires confirmation. In default mode that request must reach the parent TUI — not hang, not auto-approve.

The parked request surfaces on the footer as 1 workflow ⚠ needs approval, while the workflow tool's streamed result still shows the run live at agentsDispatched: 1, agentsCompleted: 0:

footer pill shows needs approval

↓ ↓ from the composer walks the focus chain into the roster, where the row carries the same marker:

roster row marked needs approval

Enter opens the detail view, which renders the restricted command through the same one-shot confirmation surface ordinary tools use — note there is no "always allow" option, as intended:

workflow approval confirmation in detail view

Allowing once executes only that operation: proof.txt is created containing approve, the run settles to 1 task done, the workflow returns PROOF_DONE, and the composer is restored:

run settled after approval

Denying (2. No) in the same surface is fail-closed: the Shell command never runs (no proof.txt on disk), the parent turn still completes rather than hanging, and no approval stays parked after the run settles.

deny option selected

Both PTY runs are assertion-driven, not eyeballed — each checks the on-screen strings and the real filesystem side effect. approve and deny each reported PASS (0 failures).

2. Headless transports

All three non-TUI parent transports from the test plan, run against the same bundle:

headless and stream-json transport results

Case Expected Observed
plain headless, no control channel deny, don't wait forever child got …permission was declined (non-interactive mode cannot prompt…), exit 0 in ~1s
stream-json, host allows once only the approved op runs can_use_tool emitted with a run-scoped tool_use_id (wfap_1); after behavior: allow the child received the real file content
stream-json, host denies deny, with the host's own reason child got [Operation Cancelled] Reason: denied by SDK host — i.e. 7a2ffab behaves as advertised

One methodology note worth recording for reviewers: in headless default mode the CLI already drops Shell/edit tools from the registry entirely (pre-existing policy, unrelated to this PR), so a shell request can't even be formed there. To exercise the approval path for real I forced a confirmation on a tool that does stay registered (permissions.ask: ["read_file"]). The stream-json control channel also only comes up when the first stdin message is an initialize control request — a plain user message first puts the session in direct mode with no control plane, which is easy to mistake for a routing bug.

3. Build and tests

  • Production bundle built clean from PR source; root npm run typecheck passes.
  • Suites covering every file this PR touches: core 185/185 ✓ · cli 697 passed / 1 skipped ✓ · webui 3/3 ✓ · vscode-ide-companion 51/51 ✓.
  • The two Session failures I reported in my previous round are gone at this head — they were environmental/pre-existing and no longer reproduce.

Scope of this verification

Verified end-to-end in a real environment: the Ink/TUI approval path (approve + deny, with real side effects), plain headless, and stream-json allow/deny. Not exercised live here — the ACP host path, the bundled VS Code companion, the WebUI drawer, and concurrent multi-session prompt serialization; those rest on the PR's unit tests, which pass. Background execution, durable resume, and persistent grants remain out of scope for this phase by the author's own framing.

中文版

✅ 维护者本地验证 PR #8240 —— 在最新 head 5355391 重跑

结论:通过。未发现阻塞性问题,从验证角度看可以合并。

本条替代我之前那次验证(当时在 acc6694)。此后又落了三个提交(ba531d3 合并 base、7a2ffab 转发 host 拒绝原因、5355391 abort 时推进权限队列 + 审批可观测性),因此我重新构建、重新验证。以下所有结论均来自本地全新构建的 production bundle,通过真实 macOS PTY真实 stream-json 控制通道驱动,模型端使用确定性本地 mock,不涉及任何线上 provider 或网络。

环境。 隔离 git worktree 固定在 5355391 · npm run build && npm run bundledist/cli.js--version = 0.21.2)· macOS(Darwin 25.6)· Node 22 · QWEN_CODE_ENABLE_WORKFLOWS=1 · --approval-mode default · 用工作区级 settings 预放行父会话自己的 workflow 调用,使只有子 agent 的请求需要确认。

截图见上方英文部分,编号与下文的"图 N"一一对应。

1. 前台 TUI —— 核心路径

mock 让父会话调用 workflow;脚本派发一个子 agent,该子 agent 请求需要确认的 Shell 命令(echo approve > proof.txt)。在 default 模式下,这个请求必须冒泡到父 TUI,既不能挂死,也不能自动批准。

  • 停放的请求出现在底部1 workflow ⚠ needs approval,同时 workflow 工具的流式结果仍显示 run 处于 agentsDispatched: 1, agentsCompleted: 0(图 1)。
  • 从输入框按 ↓ ↓ 沿焦点链进入任务列表,该行同样带有待审批标记(图 2)。
  • 回车进入详情页,用与普通工具完全一致的一次性确认界面渲染受限命令;如设计所述,没有"始终允许"选项(图 3)。
  • 单次允许只有该操作执行:proof.txt 被创建且内容为 approve,run 结算为 1 task done,workflow 返回 PROOF_DONE,输入框恢复(图 4)。
  • 拒绝(选 2. No)为 fail-closed:Shell 命令不执行(磁盘上无 proof.txt),父会话仍正常结束而非挂死,run 结算后无残留待审批(图 5)。

两条 PTY 链路都是断言驱动而非肉眼判断——既校验屏幕字符串,也校验真实的文件系统副作用;approvedeny 均报告 PASS(0 failures)

2. Headless 传输通道

测试计划中三条非 TUI 的父级传输,全部基于同一个 bundle 实测(图 6):

场景 期望 实测
普通 headless,无控制通道 拒绝,且不能永久等待 子 agent 收到 …permission was declined (non-interactive mode cannot prompt…),约 1 秒内 exit 0
stream-json,host 单次允许 仅被允许的操作执行 发出 can_use_tooltool_use_id 为 run 级标识(wfap_1);回 behavior: allow 后子 agent 拿到真实文件内容
stream-json,host 拒绝 拒绝,并带上 host 自己的原因 子 agent 收到 [Operation Cancelled] Reason: denied by SDK host,即 7a2ffab 行为符合描述

有两点方法学说明值得留给评审者:其一,headless default 模式下 CLI 本来就会把 Shell/编辑类工具整体移出注册表(既有策略,与本 PR 无关),因此那里根本构造不出 shell 请求;为了真正走通审批路径,我改用一个在 headless 下仍然注册的工具并强制其需要确认(permissions.ask: ["read_file"])。其二,stream-json 的控制通道只有在 stdin 第一条消息是 initialize 控制请求时才会建立;先发普通 user 消息会让会话进入 direct 模式、完全没有控制面,这一点很容易被误判成路由 bug。

3. 构建与测试

  • production bundle 从 PR 源码干净构建成功;根目录 npm run typecheck 通过。
  • 覆盖本 PR 所有改动文件的单测:core 185/185 ✓ · cli 697 通过 / 1 跳过 ✓ · webui 3/3 ✓ · vscode-ide-companion 51/51 ✓
  • 我上一轮报告的 2 个 Session 失败在当前 head 已消失——属于环境性/既有问题,现已不复现。

本次验证的边界

在真实环境端到端验证的部分:Ink/TUI 审批路径(批准与拒绝,含真实副作用)、普通 headless、stream-json 允许/拒绝。在真实环境实跑:ACP host 路径、内置 VS Code companion、WebUI 抽屉、以及并发多 Session 的审批串行化——这些依赖本 PR 的单测(均通过)。后台执行、持久恢复、持久授权按作者的定义本就不在本阶段范围内。

maintainer local verification · production bundle @ 5355391 · real PTY + real stream-json control channel

@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. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Not reviewed: You are verifying review findings for PR #8240 in the qwe... — the agent made no tool call: it read nothing.

中文说明

已审查。 建议见行内评论。 未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。 未审查:You are verifying review findings for PR #8240 in the qwe...——该 agent 未发起任何工具调用:它什么都没读。

— qwen3.8-max-preview via Qwen Code /review

updateToolCallId === pendingToolCallId &&
(updateStatus === 'completed' || updateStatus === 'failed')
) {
this.pendingPermissionResolve?.('cancel');

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] The new auto-resolve path (lines 487–496) settles the pending permission when a tool call goes terminal, but the pre-existing permissionResponse message handler (~line 556) has no stale-invocation guard. After auto-resolve clears pendingPermissionResolve, a late permissionResponse — the user clicking "No" before the webview processes the permissionResolved message and closes the drawer — still runs that handler: pendingPermissionResolve?.(optionId) is a no-op, but the handler then proceeds to isCancelcancelCurrentPrompt(), which cancels whatever the agent is doing next (not the already-settled tool call), and sends streamEnd. — Concrete cost: a terminal update arriving while the drawer is open, followed by a user "No" before the drawer closes, cancels the agent's next action and emits a spurious streamEnd; the isWorkflowApproval guard only suppresses this for workflow approvals, so ordinary (non-workflow) permission prompts are the exposed case.

Guard the handler against stale invocation once the permission is already settled:

const handler = (message: PermissionResponseMessage) => {
  if (message.type !== 'permissionResponse') return;
  if (!this.pendingPermissionResolve) return; // already settled (e.g. by tool-call auto-resolve)
  // ...existing logic
};
中文说明

新增的自动结算路径(487–496 行)会在工具调用进入终态时结算待审批,但既有的 permissionResponse 消息处理器(约 556 行)没有针对过期调用的防护。自动结算清空 pendingPermissionResolve 之后,如果用户在 webview 处理 permissionResolved 消息并关闭抽屉之前点击「No」,这个迟到的 permissionResponse 仍会执行该处理器:pendingPermissionResolve?.(optionId) 是空操作,但处理器随后会继续走到 isCancelcancelCurrentPrompt(),从而取消 agent 接下来要做的事(而不是已经结算的工具调用),并发送 streamEnd。具体代价:抽屉打开时到达终态更新、且用户在抽屉关闭前点击「No」,就会取消 agent 的下一个动作并发出一个多余的 streamEndisWorkflowApproval 防护只对 workflow 审批抑制此行为,因此普通(非 workflow)权限请求是暴露的场景。

建议在权限已结算后让处理器提前返回:

const handler = (message: PermissionResponseMessage) => {
  if (message.type !== 'permissionResponse') return;
  if (!this.pendingPermissionResolve) return; // 已结算(例如被工具调用自动结算)
  // ...原有逻辑
};

— qwen3.8-max-preview via Qwen Code /review

- Remove abort listener leak on shared turn signal in Session.ts
  by holding the listener reference and cleaning up via .finally()
- Guard stale permissionResponse in WebViewProvider after auto-resolve
- Add stream-json defensive guard to handleWorkflowApproval
- Add regression test for normal diff-based edit prompts in PermissionDrawer
- Document seenSources dedup safety in workflow-run-registry
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

Review feedback addressed

Inline comments

[rc:3694451843] Stale permissionResponse guard (WebViewProvider.ts:496)Fixed

Added if (!this.pendingPermissionResolve) return; at the top of the permissionResponse handler. After the auto-resolve path settles the pending permission (clearing pendingPermissionResolve to null via the wrapper at line 543), a late permissionResponse from the webview now exits early instead of falling through to cancelCurrentPrompt() and emitting a spurious streamEnd.

Issue-level review (@wenshao)

1. Abort-listener leak on shared turn signal (medium)Fixed

Held the abort listener in a variable and added .finally(() => signal.removeEventListener(...)) to the race promise in #requestPermissionQueued. On the happy path (transport settles before abort), the listener is now removed instead of lingering for the life of the signal. This prevents MaxListenersExceededWarning on turns with >10 confirmations.

2. Cross-session head-of-line blocking (medium, design)Deferred to maintainer

The reviewer asks whether per-session scope would remove the starvation risk without losing anything. This is a deliberate design tradeoff — the current connection-level serialization matches the ACP protocol's one-prompt-at-a-time model. Changing to per-session tails is an architecture decision that needs maintainer input on whether hosts actually render one prompt per session.

3. Oversized approvals denied rather than truncated (medium)Declined

The deny-on-oversized behavior is intentional fail-closed design. The 64KB cap (MAX_WORKFLOW_APPROVAL_DISPLAY_CHARS) was verified as load-bearing by the mutation matrix in the sandboxed verification. Truncating fileDiff for display would add complexity (truncation logic, marker text, edge cases around partial UTF-8) for an edge case unlikely in practice. The debug-only visibility concern is noted for a follow-up if real-world reports surface.

4. Programmatic denials indistinguishable from user denials (medium)Declined

Threading rejectResponder(respond, reason) touches 4 call sites, the respond callback signature, and all three transport consumers. The stream-json path already carries a reason (cancelMessage). This is a valuable enhancement but a non-trivial protocol change better suited to a follow-up PR.

5. Missing stream-json guard (low)Fixed

Added the same inputFormat !== InputFormat.STREAM_JSON defensive guard that handleTeammateApproval performs. Registration is gated on options.controlService so this shouldn't be reachable today, but the guard exists for exactly the same reason as the sibling's: to fail closed if the wiring contract is ever broken.

6. No run identity in stream-json request (low)Declined

Changing tool_use_id format from wfap_N to include runId/subagentId would be a protocol shape change affecting SDK hosts. Out of scope for this PR; noted for follow-up.

7. Synthetic tool call reports completed at approval time (low)Declined

_meta.approvalOutcome already disambiguates the actual outcome. Changing status semantics could break hosts that read it. Design note for follow-up.

8. WebUI change broader than PR describes (low)Fixed

Added a regression test asserting that a normal type: 'diff' edit prompt does NOT render the <pre> review-text block. The contentText memo only extracts from type: 'content' items, so normal diff-based edits are unaffected by the editReviewText addition.

Minor: seenSources commentFixed

Added a comment explaining why the seenSources early-return is safe: re-emission of an already-settled call is dropped because respond is idempotent via the runtime's responded set.

Minor: ACP diff as content textDeclined

Noted for follow-up. Structured diffs for workflow approvals would require carrying originalContent/newContent through the restricted DTO, which are intentionally stripped. Fine for this phase.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • vitest packages/cli Session.test.ts — 474 passed
  • vitest packages/cli permissionController.test.ts — 23 passed
  • vitest packages/core workflow-run-registry.test.ts — 50 passed
  • vitest packages/webui PermissionDrawer.test.tsx — 4 passed (including new regression test)
  • vitest packages/vscode-ide-companion WebViewProvider.test.ts — 51 passed
中文说明

已处理的评审反馈

行内评论

[rc:3694451843] 过期 permissionResponse 防护(WebViewProvider.ts:496)已修复

permissionResponse 处理器顶部添加了 if (!this.pendingPermissionResolve) return;。自动结算路径结算待审批后(通过 543 行的包装函数将 pendingPermissionResolve 清为 null),来自 webview 的迟到 permissionResponse 现在会提前退出,而不会继续走到 cancelCurrentPrompt() 并发出多余的 streamEnd

Issue 级评审(@wenshao

1. 共享 turn 信号上的 abort 监听器泄漏(中等)已修复

#requestPermissionQueued 中,将 abort 监听器保存在变量中,并在 race promise 上添加 .finally(() => signal.removeEventListener(...))。在正常路径(transport 先于 abort 结算)下,监听器现在会被移除,而不是留在信号上直到其生命周期结束。这防止了单 turn 超过 10 次确认时触发 MaxListenersExceededWarning

2. 跨 Session 队头阻塞(中等,设计)延迟至维护者决定

评审者询问按 Session 划分是否能消除饥饿风险。这是一个刻意的设计权衡——当前连接级串行化与 ACP 协议的一次一个审批模型匹配。改为按 Session 的 tail 是一个架构决策,需要维护者确认 host 是否确实按 Session 渲染审批。

3. 超大审批被拒绝而非截断(中等)已拒绝

超大即拒绝是刻意的 fail-closed 设计。64KB 上限(MAX_WORKFLOW_APPROVAL_DISPLAY_CHARS)已被沙箱验证中的突变矩阵确认为承重。截断 fileDiff 会增加复杂度(截断逻辑、标记文本、部分 UTF-8 边界情况),而这个边界情况在实践中不太可能出现。debug 级别的可见性问题已记录,待实际报告出现后跟进。

4. 程序化拒绝与用户拒绝不可区分(中等)已拒绝

rejectResponder(respond, reason) 添加原因需要修改 4 个调用点、respond 回调签名以及所有三个传输消费者。stream-json 路径已经携带原因(cancelMessage)。这是一个有价值的增强,但属于非平凡的协议变更,更适合后续 PR。

5. 缺少 stream-json 防护(低)已修复

添加了与 handleTeammateApproval 相同的 inputFormat !== InputFormat.STREAM_JSON 防御性检查。注册受 options.controlService 门控,因此今天不应可达,但该防护存在的原因与兄弟方法相同:如果接线契约被破坏,则 fail closed。

6. stream-json 请求中无 run 标识(低)已拒绝

tool_use_id 格式从 wfap_N 改为包含 runId/subagentId 将是影响 SDK host 的协议形状变更。不在本 PR 范围内;已记录待跟进。

7. 合成工具调用在审批时报告 completed(低)已拒绝

_meta.approvalOutcome 已经区分了实际结果。更改 status 语义可能会破坏读取它的 host。设计说明留待跟进。

8. WebUI 变更比 PR 描述的更广(低)已修复

添加了回归测试,断言普通 type: 'diff' 编辑审批不会渲染 <pre> 审查文本块。contentText memo 仅从 type: 'content' 项中提取文本,因此普通 diff 编辑不受 editReviewText 添加的影响。

次要:seenSources 注释已修复

添加了注释,解释 seenSources 提前返回为何安全:已结算调用的重发会被丢弃,因为 respond 通过运行时的 responded 集合具有幂等性。

次要:ACP diff 作为 content 文本已拒绝

已记录待跟进。Workflow 审批的结构化 diff 需要通过受限 DTO 携带 originalContent/newContent,而这些字段是被刻意剥离的。本阶段可以接受。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • vitest packages/cli Session.test.ts — 474 通过
  • vitest packages/cli permissionController.test.ts — 23 通过
  • vitest packages/core workflow-run-registry.test.ts — 50 通过
  • vitest packages/webui PermissionDrawer.test.tsx — 4 通过(含新回归测试)
  • vitest packages/vscode-ide-companion WebViewProvider.test.ts — 51 通过

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

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. Solid permission bubbling: allow-once only (hideAlwaysAllow), approvalId-scoped settlement, ACP single-flight serialization, session-scoped transport identity, fail-closed cleanup on all terminal paths. Raw args stay process-local. Comprehensive test coverage across all surfaces (TUI/ACP/stream-json/VS Code). ✅

@wenshao
wenshao added this pull request to the merge queue Aug 1, 2026
Merged via the queue into QwenLM:main with commit f62fc76 Aug 1, 2026
46 of 47 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.3.

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.

5 participants