Skip to content

feat(goal): start approved Web Shell proposals after their owning turn - #11360

Merged
qqqys merged 16 commits into
QwenLM:mainfrom
qqqys:feat/web-shell-goal-proposals
Sep 10, 2026
Merged

qqqys merged 16 commits into
QwenLM:mainfrom
qqqys:feat/web-shell-goal-proposals

Conversation

@qqqys

@qqqys qqqys commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Web Shell offers a drafted Goal in its existing Allow/Reject panel. Approval is applied exactly once after the proposing user turn finishes normally, and the Goal runtime starts its first working turn automatically. This is the second slice after #11284.

Why it's needed

Web Shell previously required users to copy a manual /goal set command. The new path requires a capable host and an attached prompt originator. Consent remains explicit under YOLO and permission rules, belongs to the proposing turn and the exact reviewed Goal, and is revoked when the session enters Plan mode. Leaving Plan mode does not revive that approval; a fresh approval is required.

Reviewer Test Plan

How to verify

  • Draft a Goal in Web Shell and inspect its complete objective in the existing Allow/Reject panel. Before approval there is no Goal; after Allow and normal acknowledgement, exactly one Goal starts working.
  • Reject or cancel approval, interrupt the proposing turn, trigger a protective exit, or queue new user input. The old proposal must not start autonomous work.
  • Change the reviewed stopped Goal before settlement. The current Goal must be preserved.
  • Approve a proposal, then enter Plan mode before acknowledgement finishes or while the Goal runtime is loading. No Goal starts. Switching back does not restore the old approval, while a newly approved draft can start normally. A switch during creation persistence must pause the created Goal before automatic execution is released.
  • Unsupported and anonymous contexts must not wait for an approval that nobody can answer. Terminal proposals retain the same consent and settlement behavior, and child-agent mode changes do not revoke the parent session's approval.

Evidence (Before & After)

Baseline reproduction confirmed that approving a Goal and entering Plan before the acknowledgement finished still started an active Goal and three automatic turns. Deterministic regressions reproduced creation after Plan entry, after entering and leaving Plan, and during runtime loading; the ordinary exactly-once control passed.

At head 56f115f03f720c02805259bb1ff6c5af61265655, the final Web Shell bundle passed real-browser flows for normal Allow, Reject, cancellation after Allow, and entering Plan after Allow. The three negative flows left no Goal even after a delayed acknowledgement was released. Normal Allow created one Goal and began automatic work; a controlled no-progress response fixture then exercised the expected pause after three turns. The terminal Plan-switch flow also passed on the same repair before the final upstream-only merge: no Goal was set and the user saw the revoked-approval notice.

Local verification passed: 4,270 tests across 17 affected suites, build and bundle, typecheck, focused lint, formatting, and diff checks. The final upstream merge changed session queue cleanup, so its 914 session tests plus the affected build, bundle, and typecheck were repeated on the pushed head. One bounded correctness review found no demonstrable blocker in the consent, turn ownership, settlement, and automatic-start paths. This is separate from the hosted CI result and formal GitHub review. The UI fixture validates deterministic lifecycle behavior, not a live external model's drafting quality.

Tested on

OS Status
macOS
Windows ⚠️
Linux ⚠️

Environment

An isolated worktree with Node.js 24.14.1. Real UI checks use a local controlled model fixture and separate configuration, runtime, and workspace directories. Hosted CI is reported independently of local checks.

Risk & Scope

  • Main risk: ACP turn ownership, consent invalidation, and automatic scheduling. Approval metadata remains session-local and in memory; existing permission routes and cancellation compensation are reused.
  • The remaining persistence-error wording, redundant held-Goal notice, and user/model wording distinction are tracked in fix(goal): clarify proposal settlement failure and recovery messages #11569 outside this PR. These are not requests for another implementation round here.
  • Custom proposal cards, inline editing, active-Goal replacement, and natural-language drafting quality are outside this slice. Browser reconnect and the existing panel's Escape-key behavior are not claimed as validated.
  • No settings or persisted Goal schema changes. Unsupported clients keep the manual handoff.

Linked Issues

Follow-up to #11284. Deferred recovery-message improvements: #11569.

中文说明

本 PR 的改动

Web Shell 使用现有 Allow/Reject 面板展示目标草案。批准只在提出草案的用户回合正常结束后生效一次,随后 Goal runtime 自动启动首轮工作。这是 #11284 之后的第二阶段。

为什么需要

此前用户需要手动复制 /goal set 命令。新流程要求宿主支持且存在已连接的输入发起客户端;YOLO 和许可规则不能绕过明确同意。批准绑定原回合及审阅时的 Goal,进入 Plan 模式会撤销旧批准,退出 Plan 不会恢复它,需要重新批准。

审阅者验证计划

  • 检查完整目标和现有 Allow/Reject 面板;批准前没有 Goal,正常确认结束后仅启动一次。
  • 拒绝、取消、中断、保护退出或排队的新输入都不得启动旧草案;审阅目标发生变化时保留当前 Goal。
  • 在批准后、确认结束前或加载 runtime 时进入 Plan,不得启动 Goal;退出 Plan 不恢复旧批准,重新批准的新草案可以正常启动。创建持久化过程中切换,应在释放自动执行前暂停新建 Goal。
  • 不支持或匿名环境不得等待无人响应的审批。终端保持相同的同意与结算约束;子 agent 的模式切换不影响父会话批准。

前后证据

基线真实终端复现:批准后进入 Plan,再结束确认回答,仍创建活动 Goal 并自动运行三轮。确定性测试也复现了进入 Plan、进入后退出 Plan、加载 runtime 时进入 Plan 的错误创建;正常批准仅创建一次的对照测试通过。

最终 head 56f115f03f720c02805259bb1ff6c5af61265655 的 Web Shell 构建已通过真实浏览器的正常批准、拒绝、批准后取消、批准后进入 Plan 四条路径。三条负向路径在释放迟到确认回答后仍没有 Goal;正常批准只创建一个 Goal 并开始自动工作,本地固定无进度响应随后触发三轮后的预期暂停。相同修复在最后一次仅合入上游改动前也通过真实终端 Plan 切换验证:没有设置 Goal,并向用户显示批准已撤销。

本地验证通过:17 个相关测试文件共 4,270 项测试,以及构建、bundle、类型检查、针对性 lint、格式和 diff 检查。最后一次上游合并涉及会话队列清理,因此在已推送 head 上重跑了 914 项会话测试及受影响的构建、bundle 和类型检查。一轮限定范围的正确性审查未发现同意、回合归属、结算及自动启动路径中的可证实阻断项;这与远端 CI 及 GitHub 正式评审分开报告。真实界面的本地模型 fixture 验证确定性生命周期,不代表真实外部模型的起草质量。

测试平台与环境

macOS 本地验证;Windows、Linux 未本地验证。使用独立 worktree 和 Node.js 24.14.1;真实界面验证使用本地受控模型及独立配置、运行目录和工作区。远端 CI 与本地验证分别报告。

风险与范围

  • 主要风险为 ACP 回合归属、批准撤销与自动调度;审批元数据仅保存在会话内存,复用现有审批路由及取消补偿。
  • 持久化错误提示、held Goal 重复提示、用户/模型措辞三类改进移至 fix(goal): clarify proposal settlement failure and recovery messages #11569,不再要求本 PR 下一轮修改。
  • 专用卡片、行内编辑、活动目标替换及自然语言起草质量不属于本阶段;不声明已验证浏览器重连或面板 Escape 键行为。
  • 不改变设置或持久化 Goal 格式,不支持的客户端保留手动交接。

关联

后续阶段:#11284。延期的恢复提示改进:#11569

@qqqys

qqqys commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

E2E report — ce6688c

Verified on macOS / Chromium using the final local build, a real isolated daemon and ACP child, and a controlled local OpenAI model fixture. Separate home, runtime and workspace directories were used; no production daemon or real provider was modified.

Scenario Observed result
Installed baseline The model tool schema omitted propose_goal; Chromium displayed the unavailable-tool fixture response without an approval panel.
Real /goal-draft in Web Shell Existing panel displayed the full objective and only Reject / Allow once. Goal state was null before approval.
Allow Ordinary acknowledgement ended, then exactly one Goal was created and its first runtime turn began.
Reject in a new browser session Goal remained null; zero persisted creates and zero Goal runtime turns.
Cancel while approval was pending Real HTTP session cancellation removed the dialog and showed cancellation in the browser. Goal remained null; zero creates and zero runtime turns.
Anonymous HTTP prompt Proposal schema absent, no permission request, Goal null; request completed promptly.

Persistence audit: the approval session had exactly one goal_state create. Its proposal call and result shared one call ID, one record each. The result was persisted at 08:59:38.108Z, Goal creation at 08:59:38.145Z, and first runtime turn at 08:59:38.151Z on 2026-09-08. A second normal continuation occurred after the fixture's 30-second response completed; it was not a second creation or a duplicate simultaneous first turn. Browser Pause subsequently stopped the Goal. Reject and cancel sessions also preserved one result for each proposal call.

Coverage limits: cancellation was triggered through the real HTTP endpoint, not a successful Escape-key interaction. The existing generic panel did not react to Escape. Browser reconnect was not exercised. Stale targets, protective exits, mid-turn input and cancellation/pause-write races are covered by the focused unit tests. This controlled fixture verifies lifecycle and transport, not natural-language drafting quality.

All test services and the browser were closed after verification. Build, bundle, repository typecheck, changed-file ESLint/Prettier and 3,232 targeted tests passed locally. Hosted CI is separate and was still running when this report was posted.

中文:真实 /goal-draft 已验证可显示审批并在批准后自动启动;持久化仅创建一次,工具调用/结果配对完整。拒绝、接口取消、匿名 HTTP 请求均未创建或启动目标。取消通过真实 HTTP 接口触发并由浏览器观察,不能当作 Escape 键验收通过。重连未实测;竞态和保护退出由单测覆盖。全部测试服务已关闭,本地检查通过,远端 CI 仍需独立判断。

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qqqys qqqys added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Sep 8, 2026
@qqqys

qqqys commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔄 Takeover re-armed: the round counter starts a fresh window (previous rounds no longer count toward the cap); management continues.

中文说明

🔄 已重新武装:轮次计数开启新窗口(此前轮次不再计入上限),托管继续。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR! Second slice after #11284 — the shape of this one is clear.

Template looks good ✓

Problem: an observed product gap, not theoretical hardening. Interactive terminals already get the propose_goal consent dialog; Web Shell users get a /goal set … line to copy-paste instead, because resolveInteractionMode() returns 'acp' for these sessions and the tool was gated on 'interactive' at registration. I confirmed that gate in the base tree, so the gap is real and the framing is accurate. The before/after in your E2E comment is your own report rather than something I re-ran, but the code path explains it without needing to.

Direction: aligned. This closes a hand-off that exists only because of a turn-boundary difference between the TUI and ACP hosts, and it does it by reusing the existing Allow/Reject panel rather than inventing a Goal-specific UI. Consent staying non-bypassable under YOLO and allow rules is the right call for something that starts an autonomous loop. Docs and the goal-draft skill were updated in the same change, including retracting the now-false "not yet in Web Shell" claim — appreciated.

Size: 258 production logic lines, 715 test lines, 8 docs lines (981 total, matches the 952/29 reported). Core paths are touched (packages/core/src/config/**, packages/core/src/tools/**, packages/core/src/goals/**) and the change spans three packages, so this lands under the core two-tier gate. 258 is well under the 500-line threshold, so no size escalation — but see the escalation note below, which is about confidence rather than volume. The 5:1 test-to-production ratio is doing real work here.

Approach: scope feels right, and I could not find a materially simpler path. Two things I checked specifically because they are where this kind of change usually goes wrong:

  • applyPendingGoalProposal is reused rather than forked — the new ACP settle path and the existing TUI path in client.ts share one apply implementation, so the reviewedGoal matching rule cannot drift between hosts. That is the correct factoring, and it is why the new goals/index.ts exports exist.
  • Cutting scope is already done: no custom proposal card, no inline editing, no active-Goal replacement. Those are named as out of scope rather than half-built.

One question worth your attention, not a blocker: the new info-confirmation branch in buildPermissionRequestContent is generic, so it also changes what the workflow inline-script confirmation and the hook confirmation show in ACP panels — both already set renderPromptAsPlainText: true. I verified the base returned an empty content array for info, so nothing gets duplicated, and your second test pins the unchanged contract for info confirmations without the flag. It is just unmentioned in the description; if that widening was deliberate, saying so in the PR body would save a reviewer the grep.

Risk: Stage 1e matched. packages/cli/src/acp-integration/ is one of the paths this repo's revert history correlates with post-merge reverts (acpAgent.ts, session/Session.ts, session/permissionUtils.ts), so review depth is escalated and I am naming the lane that would settle the behavioural claim in the next comment.

Escalating rather than approving. Because this touches core paths, the gate here is 100% confidence, and I am not at it — for two reasons I can name rather than a general unease. First, the invariant "declare propose_goal only during a real interactive user turn" is enforced in getFunctionDeclarations() but not in its sibling getFunctionDeclarationsFiltered(); details in the review comment. Second, the whole claim is about ACP turn-boundary timing, which is exactly what static reading cannot settle and what the mocked stream tests approximate. Neither is a defect I can prove, so this is a defer to a human, not a request for changes.

中文说明

感谢贡献!这是 #11284 之后的第二阶段,整体思路很清楚。

模板完整 ✓

**问题:**这是已观测到的产品缺口,不是理论性加固。交互式终端已经有 propose_goal 同意对话框,而 Web Shell 用户只能拿到一行 /goal set … 去复制粘贴——因为这类会话的 resolveInteractionMode() 返回 'acp',而工具注册被限定在 'interactive'。我在 base 代码树里确认了这个判断条件,所以缺口是真实存在的,PR 的描述也是准确的。E2E 评论里的 before/after 是你自己的报告、我没有重跑,但代码路径本身就能解释这个现象。

**方向:**对齐。这个改动弥合的差异仅仅源于 TUI 与 ACP host 在回合边界上的不同,而且做法是复用已有的 Allow/Reject 面板,而不是另造一套 Goal 专用 UI。对于「会启动自主循环」的操作,让同意在 YOLO 和 allow 规则下依然不可绕过,是正确的取舍。文档和 goal-draft skill 也在同一次改动里更新了,包括撤回了已经不成立的「Web Shell 尚不支持」的说法——这点很好。

**规模:**生产逻辑 258 行、测试 715 行、文档 8 行(合计 981,与报告的 952/29 一致)。改动触及核心路径(packages/core/src/config/**packages/core/src/tools/**packages/core/src/goals/**)并跨三个 package,因此落入核心两级门禁。258 行远低于 500 行阈值,所以不因规模上报——但请注意下面关于「信心」而非「体量」的上报说明。测试与生产代码 5:1 的比例在这里是真正起作用的。

**方案:**范围合理,我没有找到明显更简的路径。有两点我特意确认过,因为这类改动通常正是在这些地方出问题:

  • applyPendingGoalProposal 是被复用而不是被分叉的——新的 ACP 结算路径与 client.ts 里已有的 TUI 路径共用同一份 apply 实现,所以 reviewedGoal 匹配规则不可能在两种 host 之间走偏。这是正确的分层,也正是新增 goals/index.ts 导出的原因。
  • 该砍的范围已经砍掉了:没有自定义提议卡片、没有内联编辑、没有替换 active Goal。这些被明确列为 out of scope,而不是做了一半。

有一个问题值得你留意,但不是阻塞项:buildPermissionRequestContent 里新增的 info 分支是通用的,所以它同时改变了 workflow 内联脚本确认和 hook 确认在 ACP 面板里的展示——这两处都已经设置了 renderPromptAsPlainText: true。我确认过 base 对 info 返回的是空 content 数组,所以不会出现重复内容;你的第二个测试也钉住了「没有该标志的 info 确认」保持原有契约。只是 PR 描述里没提到这点;如果这个外扩是有意的,在正文里写一句能省掉 reviewer 一次 grep。

**风险:**Stage 1e 命中。packages/cli/src/acp-integration/ 是本仓库 revert 历史中与合并后回滚相关的路径之一(acpAgent.tssession/Session.tssession/permissionUtils.ts),因此 review 深度已升级,我也会在下一条评论里点名能够验证行为主张的沙箱通道。

**上报而非批准。**由于改动触及核心路径,这里的门禁标准是 100% 信心,而我没有达到——原因是两个我能具体说出来的点,而不是一种笼统的不安。第一,「只在真实交互用户回合内声明 propose_goal」这个不变量在 getFunctionDeclarations() 里被强制执行,但在它的姊妹方法 getFunctionDeclarationsFiltered() 里没有;细节见代码审查评论。第二,整个主张关乎 ACP 回合边界的时序,而这恰恰是静态阅读无法判定、mock 流测试只能近似的东西。两者都不是我能证明的缺陷,所以这是转交人工判断,而不是要求修改。

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

I read the base tree first and formed a baseline before opening the diff: gate the schema on a per-turn flag, park the approval with a version stamp of whatever Goal was on screen, settle at the ACP turn boundary, and reuse the existing apply function. That is essentially what this does, so I have no simpler alternative to argue for. What follows is what I could not settle by reading.

1. The per-turn declaration invariant leaks through one funnel. ToolRegistry.getFunctionDeclarations() now filters propose_goal on isGoalProposalAvailable(), and I confirmed that is the funnel Client.setTools() uses (client.ts:1155), so the two setTools() refresh sites in Session.ts do correctly make the schema appear and disappear per turn. But getFunctionDeclarationsFiltered() — the sibling, twelve methods below — builds declarations straight from this.tools.get(name) with no equivalent check. Its two callers are agents/runtime/agent-core.ts:725 (a subagent type that declares an explicit tools list) and agents/background-agent-resume.ts:1655 (advertised names on resume). Either one naming propose_goal would get the schema regardless of whether a proposal turn is live.

I do not think this is exploitable, and I want to be precise about why rather than just flagging it: execution still has to clear turn attribution, and a call that cannot be attributed to a turn gets PROPOSE_GOAL_NO_TURN_MESSAGE, while Session.ts additionally denies propose_goal unless activeGoalProposalTurn.turnKey === promptId. So the realistic outcome is a subagent seeing a tool it cannot use, not a Goal starting without consent. The reason I am raising it at all is that the invariant is new in this PR and is enforced in one of two adjacent declaration paths — that asymmetry is the kind of thing a later caller trips over. A one-line guard in the filtered variant, or a comment there saying why it is not needed, would close it.

2. Two info-confirmation flows change alongside propose_goal. Noted in the gate comment; verified as harmless here. Base buildPermissionRequestContent had no info branch at all and returned [], so there is no duplication with the tool description. The other two setters of renderPromptAsPlainText (tools/workflow/workflow.ts:373, coreToolScheduler.ts:4623) will now surface their prompt text in ACP panels too. Both already opt into plain-text rendering and the TUI already shows it, so this reads as a consistency fix rather than a regression — but it is a user-visible change to two flows the PR does not claim to touch, and neither has a test here.

3. Per-turn setTools() cost on Web Shell. The finally block and #executePromptInner each call setTools() whenever the host supports proposals, so a Web Shell turn now pays two extra refreshes: warmAll(), a revealDeferredToolsReferencedInHistory scan, declaration rebuild, and a recordStartupEvent('gemini_tools_updated'). I checked the part that could actually misbehave — reminder spam — and it is fine: queueMcpServerInstructionsReminder and queueAddedMcpToolsReminder both diff against their announced* maps and only queue on real change (client.ts:1867, client.ts:1906), so repeated refreshes stay silent. What is left is bounded work plus a startup telemetry event firing twice per Web Shell turn; worth a glance if that event feeds anything rate-based. The design needs the refresh, so I am not asking for it to go — just naming the cost.

4. Unreachable defensive branch. matchesReviewedGoal accepts reviewed: … | undefined and returns false for it, but reviewedGoal is a required field on PendingGoalProposal and the only non-test writer is ProposeGoalInvocation.execute, which always sets it. The undefined arm cannot be reached from typed callers. Harmless, and arguably correct fail-closed behaviour, but it is a branch no test can cover and no caller can hit.

What I verified as sound, since these are the claims the PR rests on:

  • Consent cannot be bypassed. hideAlwaysAllow: true is new on this confirmation, and it genuinely reaches the ACP option filter: supportsHideAlwaysAllow() excludes only ask_user_question, so info is covered, and filterAlwaysAllowOptions strips every allow_always option. The YOLO-plus-allow-rule test asserts the panel still fires and still offers only allow_once / reject_once. This holds.
  • The meta keys cannot be spoofed. bridge.ts deletes qwen.goalProposalApproval unconditionally and re-arms it only for an attached originatorClientId on a non-channel prompt; acpAgent.ts deletes it again and re-arms only when privateParentState === 'trusted'. Both directions are tested, including the channel-prompt case. An anonymous HTTP prompt therefore cannot reach the proposal path, which is what makes "must not hang waiting for approval" true.
  • No import cycle. tool-names.ts has no imports at all, so the new tool-registry.ts dependency is a leaf.
  • The new exports are consumed. goals/index.ts re-exports through packages/core/src/index.ts:787, and Session.ts plus Session.test.ts both pull applyPendingGoalProposal / ProposeGoalTool / PendingGoalProposal from the package root. Not dead surface.
  • The TUI path is not regressed. applyPendingGoalProposal is shared, and the tightened reviewedGoal rule now applies to interactive sessions as well — which is a deliberate behaviour change (a stopped Goal changed mid-dialog invalidates the approval) and is documented in the goals page. The parametrized create/edit/replace/clear × before approval/before settlement matrix is the right shape for that.

The settle ordering is worth stating explicitly, because I initially suspected a double-settle bug and it is fine: core/client.ts has its own settlePendingGoalProposal at the turn bottom, but ACP Session.ts drives LlmChat.sendMessageStream directly via #getCurrentChat() and never goes through the Client wrapper that contains it. The two settle paths are disjoint by construction — one per host — and share only the apply function.

sequenceDiagram
    participant P1 as User (Web Shell)
    participant P2 as ACP bridge
    participant P3 as Session
    participant P4 as propose_goal tool
    participant P5 as Config
    participant P6 as Goal runtime
    P1->>P2: prompt carrying approval meta
    P2->>P3: re-armed only for attached originator
    P3->>P5: set goal proposal turn key
    P3->>P5: setTools, schema now declares propose_goal
    P3->>P4: model calls propose_goal
    P4->>P5: park approval with reviewed goal stamp
    P4-->>P1: Allow or Reject panel with full objective
    P1->>P4: Allow
    P4-->>P3: tool result, model acknowledges
    P3->>P3: turn ends normally, completedNormally set
    P3->>P6: applyPendingGoalProposal
    P6-->>P3: goal active
    P6->>P3: first Goal turn starts automatically
    P3->>P5: clear turn key, setTools withdraws schema
Loading

Test evidence

This is an unattended CI run, so I did not build or execute anything from this PR — no local test run, no tmux capture, nothing derived from the fork's tree. Everything below is the PR's own CI, read through the API for the reviewed commit. Your E2E report is attributed to you throughout and is not counted as verification I performed.

Final CI results for ce6688c (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Test (ubuntu-latest, Node 22.x) ❌ failure
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
macos-latest / Java 21 ✅ success
OpenTUI no-flicker gate ✅ success
Real daemon E2E / Java 11 ✅ success
route ✅ success
TUI parity snapshots (ink vs opentui) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

No check is red. The two that matter most for this diff — the ubuntu unit suite and lint/static — were still running when I read them, and I did not poll or wait: the unit suite alone runs longer than any sensible in-agent budget, and the finalize workflow rewrites the table above in place once CI settles. So the honest state is not verified: unit tests and lint had not concluded at review time. Everything green above is real but peripheral to this change; the Desktop Shell and TUI parity jobs do not exercise the ACP proposal path.

Two gaps in coverage that CI will not close even when green. macOS and Windows unit runs are skipped, so the suite only ever executes on ubuntu — while your own Tested-on table marks Windows and Linux as untested and only macOS as verified. And the verify and tmux-testing jobs are both skipped, meaning no isolated lane has run against this head.

Sandboxed verification would settle this: @qwen-code /verify — that an approved proposal settles exactly once at the ACP turn boundary, and that the completedNormally / activeGoalProposalTurn ownership guards actually fail closed when a Stop-hook cap or a mid-turn injection ends the turn, is not observable from the diff. Those guards are the entire safety argument of this PR and the unit tests exercise them only against mocked streams, so a suite that passed with them weakened would still be green. @qwen-code /tmux is the complementary lane if you want the panel itself driven as a real user. You have write access, so both are available directly rather than as a sponsored run.

Not verified, with reasons: the Web Shell Allow/Reject panel rendering the full objective (browser surface, author-reported on macOS/Chromium only); automatic start of the first Goal turn after acknowledgement (needs a live daemon and ACP child); cancellation during persistence reaching the held-execution state and its user-visible message; and the two collateral info-confirmation flows named in finding 2, which have no test in this PR.

中文说明

代码审查

我先读了 base 代码树、在看 diff 之前形成了自己的基线方案:用一个「每回合标志」来控制 schema 是否声明,把批准连同当时屏幕上 Goal 的版本戳一起暂存,在 ACP 回合边界结算,并复用已有的 apply 函数。这个 PR 基本就是这么做的,所以我没有更简的替代方案可以主张。下面是我光靠阅读无法判定的部分。

1. 「按回合声明」这个不变量从一个出口漏了出去。 ToolRegistry.getFunctionDeclarations() 现在会用 isGoalProposalAvailable() 过滤 propose_goal,我确认了这正是 Client.setTools() 使用的出口(client.ts:1155),所以 Session.ts 里两处 setTools() 刷新确实能让 schema 按回合出现和消失。但它的姊妹方法 getFunctionDeclarationsFiltered() 直接用 this.tools.get(name) 构造声明,没有对应检查。它的两个调用方是 agents/runtime/agent-core.ts:725(声明了显式 tools 列表的 subagent 类型)和 agents/background-agent-resume.ts:1655(resume 时公布的工具名)。这两处只要有一个点名 propose_goal,就会在没有任何提议回合的情况下拿到 schema。

我不认为这可被利用,而且我想把理由说清楚,而不是只丢一个标记:执行阶段仍然要过「回合归属」这一关,无法归属到某个回合的调用会拿到 PROPOSE_GOAL_NO_TURN_MESSAGESession.ts 还额外要求 activeGoalProposalTurn.turnKey === promptId 才放行 propose_goal。所以现实后果是 subagent 看到一个自己用不了的工具,而不是在没有同意的情况下启动 Goal。我之所以还是要提,是因为这个不变量是本 PR 新引入的,却只在两条相邻的声明路径中的一条上被强制执行——这种不对称正是后来新增调用方容易踩的坑。在 filtered 变体里加一行守卫,或者加一句注释说明为什么不需要,就能补上。

2. 另外两个 info 确认流程随 propose_goal 一起变了。 门禁评论里已经提到,这里确认是无害的。base 的 buildPermissionRequestContent 完全没有 info 分支、返回 [],所以不会和工具描述重复。另外两个设置 renderPromptAsPlainText 的地方(tools/workflow/workflow.ts:373coreToolScheduler.ts:4623)现在也会在 ACP 面板里显示自己的 prompt 文本。两者本来就已经选择用纯文本渲染、TUI 也早就显示了,所以这读起来更像一致性修复而不是回归——但它确实是对两个本 PR 没有声称要改的流程的用户可见改动,而且这两处在这里都没有测试。

3. Web Shell 上每回合的 setTools() 开销。 finally 块和 #executePromptInner 在 host 支持提议时各调用一次 setTools(),所以一个 Web Shell 回合现在要多付两次刷新:warmAll()、一次 revealDeferredToolsReferencedInHistory 扫描、声明重建,以及一次 recordStartupEvent('gemini_tools_updated')。我检查了真正可能出问题的部分——提醒刷屏——它是安全的:queueMcpServerInstructionsReminderqueueAddedMcpToolsReminder 都会与各自的 announced* map 做差异比较、只在内容真正变化时入队(client.ts:1867client.ts:1906),所以反复刷新不会出声。剩下的是有界的工作量,外加一个启动埋点事件在每个 Web Shell 回合触发两次;如果这个事件被用于任何基于频率的统计,值得看一眼。这个设计确实需要刷新,所以我不是要求去掉它——只是把成本说出来。

4. 一个不可达的防御分支。 matchesReviewedGoal 接受 reviewed: … | undefined 并对其返回 false,但 reviewedGoalPendingGoalProposal 的必填字段,唯一的非测试写入方是 ProposeGoalInvocation.execute,它总会赋值。类型化的调用方到不了 undefined 那一支。无害,甚至可以说是正确的 fail-closed 行为,但这是一个测试覆盖不到、调用方也走不到的分支。

我确认为可靠的部分,因为这些正是本 PR 赖以成立的主张:

  • 同意无法被绕过。 hideAlwaysAllow: true 是这个确认上新增的,而且它确实能到达 ACP 的选项过滤器:supportsHideAlwaysAllow() 只排除 ask_user_question,所以 info 是被覆盖的,filterAlwaysAllowOptions 会剥掉所有 allow_always 选项。YOLO 加 allow 规则的测试断言了面板仍然弹出、并且只提供 allow_once / reject_once。这一点成立。
  • meta key 无法被伪造。 bridge.ts 无条件删除 qwen.goalProposalApproval,只在存在已挂载的 originatorClientId 且非 channel prompt 时重新装填;acpAgent.ts 再删一次,只在 privateParentState === 'trusted' 时重新装填。两个方向都有测试,包括 channel-prompt 的情况。因此匿名 HTTP prompt 到不了提议路径——这正是「不会挂起等待批准」得以成立的原因。
  • 没有循环依赖。 tool-names.ts 完全没有任何 import,所以 tool-registry.ts 新增的依赖是个叶子模块。
  • 新增导出确实被消费。 goals/index.ts 通过 packages/core/src/index.ts:787 向外再导出,Session.tsSession.test.ts 都从 package 根引入 applyPendingGoalProposal / ProposeGoalTool / PendingGoalProposal。不是死接口。
  • TUI 路径没有被回归。 applyPendingGoalProposal 是共享的,而收紧后的 reviewedGoal 规则现在同样适用于交互式会话——这是一个有意为之的行为变化(对话框期间被改动的 stopped Goal 会使批准失效),并且在 goals 文档页里写明了。create/edit/replace/clear × before approval/before settlement 的参数化矩阵正是这个变化该有的测试形状。

结算顺序值得明确说一句,因为我一开始怀疑存在重复结算的 bug,而它其实没问题:core/client.ts 在回合末尾有自己的 settlePendingGoalProposal,但 ACP 的 Session.ts 通过 #getCurrentChat() 直接驱动 LlmChat.sendMessageStream,从未经过包含该方法的那个 Client 包装层。两条结算路径在构造上就是互斥的——每种 host 一条——只共享 apply 函数。

(时序图见上方英文版,此处不重复。)

测试证据

这是一次无人值守的 CI 运行,所以我没有构建或执行本 PR 的任何东西——没有本地测试运行、没有 tmux 抓取、没有任何来自 fork 代码树的产物。下面全部内容都是 PR 自己的 CI,通过 API 针对被审查的那个 commit 读取。你的 E2E 报告在全文中都归属于你,不计入我亲自完成的验证。

没有任何检查是红的。对这个 diff 最关键的两项——ubuntu 单元测试套件和 lint/static——在我读取时仍在运行,而我没有轮询或等待:单是单元测试套件的运行时间就超过任何合理的 agent 等待预算,并且 CI 落定后 finalize workflow 会就地重写上面的表格。所以诚实的状态是未验证:审查时单元测试与 lint 尚未结束。上面所有绿色的项都是真实的,但都处在这次改动的外围;Desktop Shell 和 TUI parity 这两个 job 并不会走到 ACP 提议路径。

有两处覆盖缺口是 CI 即使全绿也补不上的。macOS 和 Windows 的单元测试运行是 skipped,所以套件实际上只在 ubuntu 上执行过——而你自己填写的 Tested-on 表格把 Windows 和 Linux 标为未测试、只有 macOS 标为已验证。另外 verifytmux-testing 两个 job 都是 skipped,意味着没有任何隔离通道针对这个 head 跑过。

沙箱验证可以判定这件事:@qwen-code /verify ——「已批准的提议在 ACP 回合边界恰好结算一次」,以及「当 Stop-hook 上限或回合中注入结束该回合时,completedNormally / activeGoalProposalTurn 归属守卫是否真的 fail closed」,这两点无法从 diff 观察得到。这些守卫是本 PR 全部的安全论证,而单元测试只用 mock 流来检验它们,所以一个在守卫被削弱后依然通过的套件仍然是绿的。@qwen-code /tmux 是互补通道,如果你想让面板本身被当作真实用户来驱动。你有 write 权限,所以这两个通道都可以直接触发,而不需要走 sponsored run。

未验证项及原因:Web Shell 的 Allow/Reject 面板渲染完整 objective(浏览器界面,仅由作者在 macOS/Chromium 上报告);acknowledgement 之后自动启动首个 Goal 回合(需要活的 daemon 与 ACP 子进程);持久化期间的取消能否进入 held-execution 状态及其用户可见提示;以及第 2 条发现里提到的两个附带 info 确认流程——它们在本 PR 中没有测试。

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the review is clean and I found no defect I can prove; this is deferred because it touches core on a data-backed high-risk path and the load-bearing claim is timing behaviour I could not settle statically, not because something is wrong.

Stepping back, this is a well-built slice. My independent proposal before reading the diff was: gate the schema on a per-turn flag, version-stamp the parked approval, settle at the host's turn boundary, and reuse one apply function so the two hosts cannot drift. That is what the PR does. When the review confirms your baseline rather than beating it, the useful signal is in the seams, and the seams here are good — applyPendingGoalProposal shared between the TUI and ACP paths, the two settle paths disjoint by construction (ACP drives LlmChat directly and never reaches the Client wrapper that owns the other one), and the consent guarantee actually wired through to the option filter rather than just asserted in a comment.

The test suite is the strongest part. It uses the real ProposeGoalTool against faithful config semantics rather than stubbing the thing under test, and it goes after the cases that matter: YOLO with a matching allow rule still requiring the panel, exactly-once settlement, cancellation during persistence, the Stop-hook cap not counting as a normal end, mid-turn user input invalidating the approval, and an automatic background-notification turn being refused outright. The changed-target matrix over create/edit/replace/clear in both phases is more thorough than the change strictly requires. In six months I would thank whoever wrote this, not curse them.

So why not approve. Three reasons, in descending weight.

The gate for core paths is 100% confidence, and the honest answer is that I am not there. The invariant this PR introduces — propose_goal is declared only during a real interactive user turn — holds in getFunctionDeclarations() and not in getFunctionDeclarationsFiltered(), whose two callers I can name but whose exposure I cannot fully bound by reading alone. I argued in the review that turn attribution makes it unexploitable, and I still think that is right; "I think the guard downstream catches it" is precisely the kind of reasoning the 100% bar exists to distrust.

Second, acp-integration is one of the paths this repo's own revert history flags, and this is 148 new lines in Session.ts orchestrating turn ownership, abort controllers, and automatic scheduling — the interaction most likely to fail in a way a mocked stream cannot show. #settleGoalProposal re-checks ownership before and after every await, which is the right instinct and also the part I would most want to see run against a real daemon.

Third, and most concretely: the unit suite and lint were still in progress when I read them, so there is no CI result to approve against at all. macOS and Windows unit runs are skipped, so the suite only ever executes on ubuntu, while the author's own table marks Windows and Linux untested. The browser-level behaviour — the panel rendering the full objective, the first Goal turn starting on its own — rests on a single-platform self-report. That gap is why the review comment names @qwen-code /verify and @qwen-code /tmux; a green suite here would tell us the tests pass, not that the ownership guards fail closed.

I am deliberately not leaving an approve-on-green instruction for the finalize workflow. CI landing green is necessary but not sufficient for this one, so the deferral is a human call rather than something that should resolve itself automatically when the last check flips. If a maintainer reads the two open questions in the review comment and is satisfied, an explicit approval — or a /triage re-run once CI is green and a sandboxed lane has run — is the right next step.

I could not resolve an owner to hand this to: QWEN_MAINTAINER_HANDLE is unset, the PR carries no area labels so the owner map matched nothing, and there is no prior human review to fall back on. Rather than guess a login, this is left unassigned — a maintainer with context on the Goal runtime and the ACP turn lifecycle needs to make this call. The two things I would want answered are the filtered-declaration gap (fix, or a comment explaining why it is safe) and whether the collateral change to the workflow and hook info confirmations in ACP panels was intended.

中文说明

信心:3/5 —— 审查是干净的,我没有找到任何我能证明的缺陷;之所以转交人工,是因为改动触及核心路径、且落在有数据支撑的高风险路径上,而最关键的主张是我无法靠静态阅读判定的时序行为,不是因为发现了什么问题。

退一步看整体,这是一个做得很好的切片。我在看 diff 之前自己形成的方案是:用一个按回合的标志来控制 schema 声明、给暂存的批准打上版本戳、在 host 的回合边界结算、并复用同一个 apply 函数以免两种 host 走偏。这个 PR 就是这么做的。当审查只是印证了你的基线方案而没有超越它时,有用的信号就在接缝处,而这里的接缝是好的——applyPendingGoalProposal 在 TUI 与 ACP 路径之间共享;两条结算路径在构造上互斥(ACP 直接驱动 LlmChat,从不到达持有另一条路径的那个 Client 包装层);同意保证是真正接线到选项过滤器上的,而不只是在注释里声称一句。

测试套件是最强的部分。它用真实的 ProposeGoalTool 配合忠实的 config 语义,而不是把被测对象本身 stub 掉;而且它盯的正是该盯的场景:YOLO 且存在匹配 allow 规则时仍然要求面板、恰好结算一次、持久化期间的取消、Stop-hook 上限不算作正常结束、回合中注入的用户输入使批准失效、以及自动的后台通知回合被直接拒绝。两个阶段上对 create/edit/replace/clear 的目标变更矩阵,比这次改动严格所需的还要周全。半年后回看,我会感谢写这些测试的人,而不是骂他。

那么为什么不批准。三条理由,按权重递减。

核心路径的门禁标准是 100% 信心,而诚实的回答是我没到。本 PR 引入的不变量——propose_goal 只在真实交互用户回合内被声明——在 getFunctionDeclarations() 里成立,在 getFunctionDeclarationsFiltered() 里不成立;后者的两个调用方我能点名,但其暴露面我无法只靠阅读完全界定。我在审查评论里论证了「回合归属」使其不可利用,我现在仍然认为这个判断是对的;但「我认为下游守卫会兜住它」恰恰正是 100% 这道门槛存在所要不信任的那种推理。

第二,acp-integration 是本仓库自身 revert 历史标记出的路径之一,而这次在 Session.ts 里新增了 148 行,编排回合归属、abort controller 与自动调度——这正是最容易以 mock 流无法呈现的方式失败的交互。#settleGoalProposal 在每次 await 前后都重新检查归属,这是正确的直觉,也正是我最希望看到它在真实 daemon 上跑一遍的部分。

第三,也是最具体的一条:我读取时单元测试套件和 lint 仍在进行中,所以根本没有任何 CI 结果可以据以批准。macOS 和 Windows 的单元测试运行是 skipped,因此套件实际上只在 ubuntu 上执行过;而作者自己的表格把 Windows 和 Linux 标为未测试。浏览器层面的行为——面板渲染完整 objective、首个 Goal 回合自行启动——依赖单一平台的自述报告。这个缺口正是审查评论里点名 @qwen-code /verify@qwen-code /tmux 的原因;这里一个全绿的套件只能告诉我们测试通过了,而不能告诉我们归属守卫确实 fail closed。

我特意没有留下「CI 全绿即批准」的指令给 finalize workflow。对这个 PR 来说,CI 变绿是必要条件但不是充分条件,所以这次转交应当由人来定,而不应该在最后一个检查翻绿时自动化解。如果 maintainer 读完审查评论里那两个待答问题后认为可以接受,那么明确的批准——或者在 CI 全绿且沙箱通道跑过之后再触发一次 /triage——才是正确的下一步。

我没能解析出该交给谁:QWEN_MAINTAINER_HANDLE 未设置,PR 没有 area 标签所以 owner map 没有匹配到任何区域,也没有在先的人工 review 可供兜底。与其猜一个 login,这里选择不指派——需要由熟悉 Goal runtime 与 ACP 回合生命周期的 maintainer 来做这个决定。 我希望得到答复的两点是:filtered 声明路径的缺口(修掉它,或者加一句注释说明为什么它是安全的),以及对 ACP 面板里 workflow 与 hook 的 info 确认产生的附带改动是否是有意为之。

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

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

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review of PR #11360 — feat(goal): start approved Web Shell proposals after their owning turn

Head: ce6688c32ed · Effort: high · Mode: COMMENT

Summary

This is the second slice after #11284, enabling Web Shell (ACP) sessions to use the propose_goal tool with an Allow/Reject approval panel, and automatically starting the approved Goal after the proposing turn ends normally. The change is well-structured with strong test coverage (715 test lines, 258 production logic, 8 docs).

I reviewed the diff, the base code, and the existing triage analysis. The triage — posted by qwen-code-ci-bot — already provided a thorough analysis. I verified the triage's key claims against the code at HEAD ce6688c32ed:

Verified sound:

  • Consent cannot be bypassed under YOLO/allow rules: hideAlwaysAllow: true is set on the info confirmation, supportsHideAlwaysAllow() excludes only ask_user_question, and filterAlwaysAllowOptions strips allow_always options. The ACP permission flow (permissionUtils.ts:30-49) correctly handles this.
  • Meta-key spoofing is prevented: bridge.ts strips qwen.goalProposalApproval unconditionally and re-arms it only for an attached originator on a non-channel prompt. acpAgent.ts re-arms it only when privateParentState === 'trusted'. Both paths are tested.
  • The isGoalProposalAvailable() gate correctly scopes schema visibility: returns true for interactive mode OR when both host support and turn key are set. The turn key is set in #executePromptInner and cleared in the finally block.
  • matchesReviewedGoal is correctly implemented: records the goal at review time, validates at execution time, and rejects if the goal changed. Covered by the create/edit/replace/clear × before approval/before settlement parameterized matrix.
  • No import cycles: tool-names.ts is a leaf import.

Triage findings confirmed (no new Criticals):

  1. (Suggestion) getFunctionDeclarationsFiltered() at tool-registry.ts:896 lacks the propose_goal filter present in getFunctionDeclarations(). I confirmed neither subagent (builtin-agents.ts) nor background-agent-resume (background-agent-resume.ts:1610) currently declares propose_goal in their tool lists, and EXCLUDED_TOOLS_FOR_SUBAGENTS (agent-core.ts:182) does not include it either. The gap is theoretical — execution is guarded at runToolCalls in Session.ts:11935 — but the asymmetry remains.

  2. (Suggestion) The info-confirmation branch in buildPermissionRequestContent (permissionUtils.ts:180-185) is generic and changes behavior for workflow inline-script and hook confirmations that already set renderPromptAsPlainText: true. Base returned [] for info, so there is no duplication; the two tests (forward-the-complete-plain-text and preserve-existing-contract) pin the split. The widening is unmentioned in the PR description.

CI Status

Check State
Test (ubuntu-latest, Node 22.x) ❌ FAILURE (infra: disk full)
Lint & Static (ubuntu-latest) ✅ SUCCESS
web-shell E2E Smoke ✅ SUCCESS
Integration Tests (no-AK, No Sandbox) ✅ SUCCESS
Desktop Shell (ubuntu-22.04) ✅ SUCCESS
Desktop Shell (windows-2022) ✅ SUCCESS

The Test (ubuntu-latest, Node 22.x) check failure is infrastructure-related (disk full / ENOSPC on the runner), not code-related. All test suites that completed passed.

Verdict

No Criticals. The triage's findings remain accurate at HEAD. The code is architecturally sound, consent is non-bypassable, and the test coverage is comprehensive.

Qwen Code · review 11360

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 3)": opening node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.d.ts / zod.gen.d.ts to confirm whether the generated ClientCapabilities declares and p…; "agent reverse-audit (round 3)": reading the GoalStatus union to determine whether paused is distinct from stopped , which is what would decide whether the goals.md:78 version-stamp senten…; "agent reverse-audit (round 2)": did not enumerate every caller of bridgeApi.sendPrompt in packages/cli/src/serve for a channel-originated prompt that carries a clientId but no channelPr…; "agent reverse-audit (round 1)": whether the interactive/TUI path settles the same approved proposal at its own Stop-hook cap — client.ts:4443-4460 emits the warning and return turn and I did…; "agent reverse-audit (round 1)": the shipped default of getStopHookBlockingCap — the test mocks it to 1 ; the drop is reachable at any cap, but I did not confirm how many blocking iterations….

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

中文说明

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

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)"opening node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.d.ts / zod.gen.d.ts to confirm whether the generated ClientCapabilities declares and p…"agent reverse-audit (round 3)"reading the GoalStatus union to determine whether paused is distinct from stopped , which is what would decide whether the goals.md:78 version-stamp senten…"agent reverse-audit (round 2)"did not enumerate every caller of bridgeApi.sendPrompt in packages/cli/src/serve for a channel-originated prompt that carries a clientId but no channelPr…"agent reverse-audit (round 1)"whether the interactive/TUI path settles the same approved proposal at its own Stop-hook cap — client.ts:4443-4460 emits the warning and return turn and I did…"agent reverse-audit (round 1)"the shipped default of getStopHookBlockingCap — the test mocks it to 1 ; the drop is reachable at any cap, but I did not confirm how many blocking iterations…

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/cli/src/acp-integration/acpAgent.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread docs/users/features/goals.md Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/core/src/config/config.ts
Comment thread packages/core/src/goals/goal-tools.ts
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

中文说明

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

@qqqys

qqqys commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover stop

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

Copy link
Copy Markdown
Collaborator

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

中文说明

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

  • R1-5 both host-supported turn-key branches in Session are executed by no test — packages/cli/src/acp-integration/session/Session.ts:5353 — already reported (comment 3959384231)
  • R1-6 neither user-visible settlement failure emission is reached by any test — packages/cli/src/acp-integration/session/Session.ts:5308 — already reported (comment 3959384214)
  • R1-8 both declaration refreshes run on every prompt regardless of whether the turn key changed — packages/cli/src/acp-integration/session/Session.ts:4994 — already reported (comment 3959384219)
  • R1-9 the interactive arm of isGoalProposalAvailable has no test — packages/core/src/config/config.ts:8043 — already reported (comment 3959384240)
  • R1-14 the automatic-turns hand-off promise is false on the interactive arm — docs/users/features/goals.md:80 — already reported (comment 3959384175)
  • R1-16 every path that discards an already-approved proposal is silent — packages/cli/src/acp-integration/session/Session.ts:8728 — already reported (comment 3959384200)
  • R1-17 heldGoalProposal is read only by the automatic queue drain — packages/cli/src/acp-integration/session/Session.ts:2552 — already reported (comment 3959384188)
  • R1-19 a model-directed refusal string reaches the user transcript — packages/core/src/goals/goal-tools.ts:746 — already reported (comment 3959384250)
  • R1-28 the only Goal-pause site that does not split session-close from user-interrupt — packages/cli/src/acp-integration/session/Session.ts:5284 — already reported (comment 3959384208)

Not reviewed: test-efficacy probe — all 9 probes inconclusive and the positive control never ran (no probe file was green in the unmutated baseline, because of the 12 net-new failures), so whether this PR's new tests would catch a wrong implementation is unmeasured.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 3)": whether continueSession ( bridge.ts:12632 , which forwards context.clientId and so arms the grant) is ever invoked by a non-user-initiated continuation — I …; "agent reverse-audit (round 3)": whether channelTurn at Session.ts:5326 is derived from CHANNEL_PROMPT_META_KEY (the reference at Session.ts:4869 suggests it is) — that derivation is th…; "agent reverse-audit (round 3)": whether a delivery -bearing prompt on a non-channel session ( serve/routes/session.ts:6803-6823 takes body['delivery'] with shape validation only, and chan…; "agent reverse-audit (round 5)": whether extractParentToolNames (agent-core.ts:242) filters propose_goal out of the advertisedNames fed to the ungated getFunctionDeclarationsFiltered at…; "agent reverse-audit (round 2)": verifying whether a subagent spawned from an ACP session actually receives propose_goal in its declared tool list — i.e. whether rebuildToolRegistryOnOverrid…, and 9 more.

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

中文说明

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

本轮确认的 9 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):test-efficacy probe — all 9 probes inconclusive and the positive control never ran (no probe file was green in the unmutated baseline, because of the 12 net-new failures), so whether this PR's new tests would catch a wrong implementation is unmeasured.

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)"whether continueSession ( bridge.ts:12632 , which forwards context.clientId and so arms the grant) is ever invoked by a non-user-initiated continuation — I …"agent reverse-audit (round 3)"whether channelTurn at Session.ts:5326 is derived from CHANNEL_PROMPT_META_KEY (the reference at Session.ts:4869 suggests it is) — that derivation is th…"agent reverse-audit (round 3)"whether a delivery -bearing prompt on a non-channel session ( serve/routes/session.ts:6803-6823 takes body['delivery'] with shape validation only, and chan…"agent reverse-audit (round 5)"whether extractParentToolNames (agent-core.ts:242) filters propose_goal out of the advertisedNames fed to the ungated getFunctionDeclarationsFiltered at…"agent reverse-audit (round 2)"verifying whether a subagent spawned from an ACP session actually receives propose_goal in its declared tool list — i.e. whether rebuildToolRegistryOnOverrid…,另有 9 条。

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

— qwen3.8-max via Qwen Code /review (v0.23.1)

Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/bridge.ts Outdated
Comment thread packages/cli/src/acp-integration/acpAgent.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/core/src/goals/goal-tools.ts Outdated
Comment thread packages/core/src/tools/tool-registry.test.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/core/src/tools/tool-registry.ts Outdated
… to a queued prompt (QwenLM#11360)

#runStopContinuation's skipped-send funnel returned a plain end_turn for
the queued-prompt and unreliable-drain stops without marking the turn's
settlementBlocked flag, so a parked proposal approved earlier in the turn
was applied even though the turn ceded to the queued user prompt. Set the
flag at those three stops, mirroring the loop-level exits, and pin each
with a regression that fails without it.

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

Copy link
Copy Markdown
Collaborator

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

Address summary — PR #11360 (round: R5 findings)

One commit: 8893cf05a1fix(goal): block proposal settlement when a guard continuation yields to a queued prompt (#11360) (+156 lines: 12 source, 144 test).

Mode constraints applied

  • Critical-only mode is active for this window (round counter reached 5), and a budget warning is in effect (the previous round exhausted the agent time budget). Per the round rules this batch addresses the smallest blocking subset: the one open Critical, R5-1. The round-5 Suggestions are real and remain open; each is deferred to the next round with a per-thread reply (comment-replies.json), not silently dropped.
  • No merge was needed (--conflict false); no failed or still-red checks were listed.
  • No growth audit was required (no Growth audit required section; the window is within its 400/400 budgets).

Fixed this round

R5-1 [Critical] (rc:3972497757) — #runStopContinuation's skipped-send funnel settled approved proposals after the turn yielded to a queued user prompt.

Verified against the code before editing: the third #runStopContinuation call in #handleStopHookLoop (the Stop-hook/todo-guard continuation) has no blockGoalProposalSettlement() ahead of it, and inside the continuation's prepareBeforeCompression hook three stops return {kind: 'stop', stopReason: 'end_turn'} without setting settlementBlocked — the unreliable-drain stop, the empty-drain probe === 'queued' stop, and the final claim === 'queued' stop. The funnel then returns {kind: 'terminal', stopReason: 'end_turn'}, so completedNormally computes true and #settleGoalProposal dispatches create even though the turn ceded to the queued user prompt — racing that prompt. The loop-level exits for the same states already block settlement; this funnel did not.

Fix (Session.ts, +12 lines): a blockGoalProposalSettlement() local in #runStopContinuation (mirroring the loop-level sibling, writing options.responseCapture.goalProposalTurn.settlementBlocked) called at exactly those three stops. Deliberately not blocked, per the finding's scope: the guard-declines-at-send-time stop (a normally-finished turn), the claim/probe === 'unavailable' stops, the compression-failure stop, and the drained-parts stop (already covered via #buildMidTurnParts).

Tests (Session.test.ts, +144 lines, three new cases in ordinary-turn Goal proposals): each parks an approved proposal, forces a todo-stop-guard continuation (decide mocked to continue), and drives one of the three stops via extMethod drain/claim mocks; each asserts stopReason: 'end_turn', dispatch never called with {action:'create'}, the pending proposal consumed, and the /goal set ${objective} not-started recovery line emitted.

Reproduction (before the fix): all three new tests failed on the pre-round code — mockGoalRuntime.dispatch was called with {action: 'create', objective} on each path, exactly the reported defect. After the fix all three pass.

Mutation probes (each guard's own witness): removing the block at the unreliable-drain stop fails only …stops on an unreliable queue drain; removing it at the probe stop fails only …yields to a queued prompt before sending; removing it at the final-claim stop fails only …claims the guard continuation at send time (each run: 1 failed / 6 passed of the matched set). Restored and re-run green after each probe.

Resolved in earlier commits, re-verified at this HEAD

Listed in resolved-comments.txt: R1-15 (bridge.ts now re-checks entry.clientIds.has(originatorClientId) for promoted mid-turn prompts), R1-2 (host support is capability-only at acpAgent.ts:13985-13987, no provisionalWorkspace clause), R1-1 (the two sibling Session harnesses pass 12/12), R2-1 (bridge excludes restore/continue turns from the grant), R2-2 (completedNormally is computed once at the outer turn boundary, Session.ts:5003-5006), R3-1 (both recovery formatters interpolate the approved objective, goal-tools.ts:627-631), R4-1 (held-Goal recovery commands are on their own lines, Session.ts:439-440), R2-7/R3-9 (isToolDeclared gates getFunctionDeclarations, getFunctionDeclarationsFiltered, getDeferredToolSummary, and the scheduler's suggestion list), and the remaining round-1–4 Suggestion threads, which carry author fix replies and were not re-raised by the round-5 review.

Deferred to the next round (threads left open, replies posted)

  • R5-2 (rc:3972497760, rc:3972497765, rc:3972497770): the unavailable settlement arm maps a persistence failure to a /goal set retry that cannot succeed; needs a distinct kind mapped to PROPOSE_GOAL_UNAVAILABLE_MESSAGE in both hosts. One mechanism spanning three sites — must change together, so deferred as a unit.
  • R5-3 (rc:3972497775): settlement re-validation pins Goal identity but never re-checks a mid-turn switch to Plan mode.
  • R5-4 (rc:3972497783): the held-Goal notice also fires on the very /goal pause prompt it instructs.
  • R5-7 / R1-19 re-exposed (rc:3972497792, original thread rc:3959384250): the changed settlement path publishes the model-directed PROPOSE_GOAL_CHANGED_MESSAGE verbatim to the user in both hosts.

Reason for deferral, identical for each: critical-only mode plus an exhausted budget last round caps this batch at the smallest blocking subset; these are Suggestion-level and each needs its own witness tests. None is declined on the merits.

Verification

  • npm run build — passed (exit 0; no errors).
  • npm run typecheck — passed (exit 0).
  • npm run lint — passed (exit 0).
  • npx prettier --check packages/cli/src/acp-integration/session/Session.ts packages/cli/src/acp-integration/session/Session.test.ts — passed.
  • Reproduction run (pre-fix), cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts -t 'does not start a Goal when' — 3 failed / 4 passed: the three new tests failed with dispatch called as {action: 'create', objective}, demonstrating the defect.
  • Post-fix, cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts -t 'ordinary-turn Goal proposals' — 24 passed.
  • Post-fix, full file: cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts — 897 passed (897).
  • Sibling harnesses (R1-1 re-verification): npx vitest run src/acp-integration/session/Session.review-lease.test.ts src/acp-integration/session/Session.worktree.test.ts — 12 passed (12).
  • Mutation probes: three single-site removals of the new blockGoalProposalSettlement() calls; each failed exactly its own new test (1 failed / 6 passed per run) and was restored to green.
  • Integration tests: not run — the touched behavior is exercised by the package unit suite above, not only through the bundled CLI or integration harness.
中文说明

处理摘要 — PR #11360(本轮:R5 发现)

一个提交:8893cf05a1 —— fix(goal): block proposal settlement when a guard continuation yields to a queued prompt (#11360)(+156 行:源码 12 行、测试 144 行)。

本轮应用的模式约束

  • 本窗口已进入仅处理 Critical 的模式(轮次计数已达 5),且存在预算警告(上一轮已耗尽 agent 时间预算)。按本轮规则,本批次只处理最小阻断子集:唯一仍开放的 Critical——R5-1。第 5 轮的各条 Suggestion 均为真实问题并保持开放;每条都通过逐线程回复(comment-replies.json)延后到下一轮,而非被静默丢弃。
  • 无需合并(--conflict false);没有列出失败或持续红色的检查。
  • 不需要增长审计(没有 Growth audit required 小节;窗口在 400/400 预算之内)。

本轮修复

R5-1 [Critical](rc:3972497757)——#runStopContinuation 的“跳过发送”汇聚处在回合让位给排队的用户 prompt 后仍然结算了已批准的提案。

修改前已对代码核实:#handleStopHookLoop 中第三个 #runStopContinuation 调用(Stop hook / todo 守卫续跑)之前没有 blockGoalProposalSettlement(),且续跑的 prepareBeforeCompression 钩子内有三个停止点在返回 {kind: 'stop', stopReason: 'end_turn'} 时未设置 settlementBlocked——不可靠 drain 停止点、空 drain 的 probe === 'queued' 停止点、以及最后的 claim === 'queued' 停止点。汇聚处随后返回 {kind: 'terminal', stopReason: 'end_turn'},于是 completedNormally 计算为 true,#settleGoalProposal 在回合已让位给排队用户 prompt 的情况下仍然派发 create——与该 prompt 形成竞争。相同状态下循环层的出口本已阻断结算,而这个汇聚处没有。

修复(Session.ts,+12 行):在 #runStopContinuation 内新增一个 blockGoalProposalSettlement() 局部函数(与循环层的同类写法一致,写入 options.responseCapture.goalProposalTurn.settlementBlocked),并恰好在这三个停止点调用。按发现的范围,刻意不阻断:发送时守卫重新判定后拒绝继续的停止点(属于正常完成的回合)、claim/probe === 'unavailable' 停止点、压缩失败停止点,以及带有 drain 内容的停止点(已由 #buildMidTurnParts 覆盖)。

测试(Session.test.ts,+144 行,在 ordinary-turn Goal proposals 中新增三个用例):每个用例先停放一个已批准的提案,强制触发 todo-stop-guard 续跑(decide mock 为 continue),再通过 extMethod 的 drain/claim mock 驱动其中一个停止点;每个用例都断言 stopReason: 'end_turn'dispatch 从未以 {action:'create'} 被调用、停放的提案已被消费,并且发出了 /goal set ${objective} 的“未启动”恢复提示。

**复现(修复前):**三个新测试在改动前的代码上全部失败——每条路径上 mockGoalRuntime.dispatch 都以 {action: 'create', objective} 被调用,正是所报告的缺陷。修复后三者全部通过。

**变异探针(每个守卫各自的见证):**移除不可靠 drain 停止点的阻断后,只有 …stops on an unreliable queue drain 失败;移除 probe 停止点的阻断后,只有 …yields to a queued prompt before sending 失败;移除最终 claim 停止点的阻断后,只有 …claims the guard continuation at send time 失败(每次运行:匹配集合中 1 失败 / 6 通过)。每次探针后均已还原并复跑为绿。

已在更早提交中解决、并在当前 HEAD 重新核实

已列入 resolved-comments.txt:R1-15(bridge.ts 现在对被提升的 mid-turn prompt 重新检查 entry.clientIds.has(originatorClientId))、R1-2(宿主支持在 acpAgent.ts:13985-13987 仅取决于协商能力,不再有 provisionalWorkspace 子句)、R1-1(两个兄弟 Session 测试脚手架 12/12 通过)、R2-1(bridge 把 restore/continue 回合排除在授权之外)、R2-2(completedNormally 在外层回合边界统一计算,Session.ts:5003-5006)、R3-1(两个恢复格式化函数都插值了已批准的 objective,goal-tools.ts:627-631)、R4-1(held-Goal 恢复命令各自独占一行,Session.ts:439-440)、R2-7/R3-9(isToolDeclared 统一约束 getFunctionDeclarationsgetFunctionDeclarationsFilteredgetDeferredToolSummary 以及调度器的建议列表),以及其余第 1–4 轮的 Suggestion 线程——它们都有作者的修复回复,且第 5 轮评审未再次提出。

延后到下一轮(线程保持开放,已逐条回复)

  • R5-2rc:3972497760rc:3972497765rc:3972497770):unavailable 结算分支把持久化失败映射到一条不可能成功的 /goal set 重试建议;需要一个独立的 kind,在两个宿主中都映射到 PROPOSE_GOAL_UNAVAILABLE_MESSAGE。同一机制横跨三处——必须一起修改,因此作为整体延后。
  • R5-3rc:3972497775):结算期复核只固定 Goal 身份,从未重新检查回合中途切换到 Plan 模式的情况。
  • R5-4rc:3972497783):held-Goal 提示也会在用户按其指示发送的 /goal pause 上再次触发。
  • R5-7 / R1-19 重新暴露rc:3972497792,原线程 rc:3959384250):changed 结算路径把面向模型的 PROPOSE_GOAL_CHANGED_MESSAGE 逐字发布给两个宿主的用户。

延后理由对每条相同:仅 Critical 模式加上上一轮预算耗尽,本批次上限就是最小阻断子集;这些都是 Suggestion 级,且各自需要独立的见证测试。没有任何一条是因价值判断而被拒绝。

验证

  • npm run build —— 通过(退出码 0,无错误)。
  • npm run typecheck —— 通过(退出码 0)。
  • npm run lint —— 通过(退出码 0)。
  • npx prettier --check packages/cli/src/acp-integration/session/Session.ts packages/cli/src/acp-integration/session/Session.test.ts —— 通过。
  • 复现运行(修复前),cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts -t 'does not start a Goal when' —— 3 失败 / 4 通过:三个新测试均以 dispatch 被以 {action: 'create', objective} 调用而失败,证明了该缺陷。
  • 修复后,cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts -t 'ordinary-turn Goal proposals' —— 24 通过。
  • 修复后,整个文件:cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts —— 897 通过(897)。
  • 兄弟脚手架(R1-1 复核):npx vitest run src/acp-integration/session/Session.review-lease.test.ts src/acp-integration/session/Session.worktree.test.ts —— 12 通过(12)。
  • 变异探针:对新增的 blockGoalProposalSettlement() 调用做了三次单点移除;每次都恰好只有对应的新测试失败(每次运行 1 失败 / 6 通过),随后均还原为绿。
  • 集成测试:未运行——本次触及的行为由上述包级单元测试覆盖,并非只能通过打包后的 CLI 或集成测试框架才能触达。

Deferred non-Critical feedback

Critical-only mode is active: the round counter reached 5 (this window was seeded at round 4 by @qwen-code /takeover from 4, plus 1 change-producing round(s) since). The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:轮次计数已达 5(本窗口由 @qwen-code /takeover from 4 从第 4 轮起算,此后又完成 1 个产生改动的轮次)。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

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

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

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


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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 3182 seconds (of the 360-minute budget). This run already used the maximum 360 minute timeout. See workflow logs.

@qqqys

qqqys commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover stop

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

Copy link
Copy Markdown
Collaborator

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

中文说明

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

…temp dirs (QwenLM#11360)

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

Copy link
Copy Markdown
Collaborator

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

Autofix round summary — PR #11360

What this round did

Fixed the deterministic verification rejection from the previous round with
one follow-up commit (1248cfd698). The rejected merge commit (3a3472957f)
is preserved unchanged. The base-conflict resolution was already complete —
origin/main (fe7cf68644) is the merge base of HEAD — so no new merge was
needed.

Root cause of the rejection (diagnosed from evidence)

The gate failed on
src/commands/review/ab-drive.test.ts > drives both arms with one script and pairs the captures with AssertionError: expected 1 to be +0 at the
assertion expect(runDirs()).toBe(before). That assertion counts
qwen-review-ab-drive- directories in the machine-wide os.tmpdir() before
and after a fully synchronous, fake-harnessed run — so a leak from this
process is impossible, and the count can only move if a foreign process
creates a matching dir in the shared /tmp between the two reads. The review
pipeline runs qwen review ab-drive for real (its run dirs live for the whole
drive window, seconds to minutes), and shared runners execute several jobs on
one machine. While this round's own suite ran, a foreign
/tmp/qwen-review-ab-drive-* dir was directly observed appearing and being
swept — the pollution source is live, not hypothetical. The test file is
byte-identical to origin/main (last touched 2026-08-28); nothing in this PR
reaches it.

The fix

The two tests that count run dirs in the global temp dir
(ab-drive.test.ts:577 harnessed, and the real-drive sibling at the old
:1064/:1083) now count inside a private TMPDIR — the same
save/redirect/restore pattern this file already uses for its TMPDIR-mutation
test. The assertions themselves are unchanged: the run dir must still be
swept, and before/after must still match. On Windows the redirect is a
documented no-op and the count behaves exactly as before.

A new witness test (the sweep count is hermetic — a foreign run dir in the shared temp dir does not move it, POSIX-only) plants a foreign
qwen-review-ab-drive- dir in the machine-wide temp dir mid-run via the
harness's onSession hook and asserts the private count does not move —
pinning the mechanism that fixed the gate failure.

Mutation probe (required before committing): with the redirect temporarily
removed from the witness, it fails with AssertionError: expected 1 to be +0
the gate's exact rejection signature — proving both the diagnosed mechanism
and that the witness catches its removal. Redirect restored; file green again
(44 passed, 9 skipped — the skips are the real-tmux describes; tmux is absent
on this machine, so the sibling's fixed form is exercised only where tmux
exists, as before).

Feedback dispositions

  • Actionable sections (Reviews / Inline comments / Issue-level comments):
    empty again this round. Critical-only mode remains active; there was no
    actionable review feedback to address.
  • Deferred non-Critical feedback (@qwen-code-ci-bot PR comment):
    untouched, per Critical-only mode — that section is an audit record, not
    work for this round.
  • Failed check review-pr: unchanged assessment from the previous round —
    the automated reviewer job itself failed after ~6h and the workflow's
    fallback-comment job completed; a failure of the review automation, not of
    this PR's code. Nothing in scope can fix it; .github/ is out of bounds.
  • Same-run verification repair: addressed — this commit fixes the supplied
    deterministic rejection, verified by re-running the gate's exact command to
    green (below).

Footprint note

This commit touches packages/cli/src/commands/review/ab-drive.test.ts, which
the PR itself never touched. The expansion is not gratuitous: the
deterministic gate rejected the previous commit on exactly this test, and the
same-run verification repair directive requires fixing that rejection. The
change is test-only, assertion-preserving, and adds no production surface.

Verification

  • npx vitest run src/commands/review/ab-drive.test.ts (packages/cli,
    pre-fix baseline) — 43 passed, 9 skipped (the gate failure does not
    reproduce on a quiet machine, consistent with the race diagnosis)
  • Mutation probe: redirect removed from the witness → witness fails with
    expected 1 to be +0 (the gate's exact signature); restored → green
  • npx vitest run src/commands/review/ab-drive.test.ts (packages/cli,
    post-fix) — 44 passed, 9 skipped (incl. the new witness)
  • npx prettier --check on the touched file — initially flagged, fixed with
    --write, now clean
  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • Gate's exact command, vitest run --changed origin/main --passWithNoTests --maxWorkers=25% --testTimeout=60000 --hookTimeout=60000 --coverage.enabled=false (packages/cli):
    • first local run, agent-shell env — 31 failures in unrelated suites
      (config/settings/Footer/docsCommand/ideCommand/...); diagnosed as the
      agent session's SANDBOX / QWEN_HOME / QWEN_CODE_* env vars flipping
      environment-detection branches the gate's clean env does not
    • scrubbed env — 4 failures left, all EACCES: permission denied on
      /home/github-runner (root-owned in this sandbox; the tests mkdtemp
      inside $HOME)
    • the same 4 files re-run with a writable HOME=/tmp/fake-home — 939
      passed, proving the EACCES theory
    • scrubbed env + writable HOME779 passed (779 files), 25725 passed,
      0 failed, exit 0
      — the exact command that rejected the previous commit
      now fully green
中文说明

Autofix 本轮总结 — PR #11360

本轮完成的工作

用一个后续提交(1248cfd698)修复了上一轮被确定性验证拒绝的问题。被拒绝的合并
提交(3a3472957f)原样保留。基线冲突解决在上一轮已经完成——origin/main
fe7cf68644)已是 HEAD 的合并基点——因此本轮无需再次合并。

拒绝原因(基于证据的诊断)

门禁在 src/commands/review/ab-drive.test.ts > drives both arms with one script and pairs the captures 上失败,断言 expect(runDirs()).toBe(before)
AssertionError: expected 1 to be +0。该断言在一次完全同步、使用假 harness 的运行
前后,统计机器级共享 os.tmpdir()qwen-review-ab-drive- 目录的数量——本进程
不可能泄漏,因此计数变化只可能来自外部进程在两次读取之间于共享 /tmp 中创建了
同名前缀目录。评审流水线会真实运行 qwen review ab-drive(其运行目录在整个驱动
窗口内存在,长达数秒到数分钟),而共享 Runner 一台机器上会并行执行多个任务。本轮
自己的测试运行期间,确实直接观测到一个外部的 /tmp/qwen-review-ab-drive-* 目录
出现并被清理——污染源是真实存在、正在活动的,并非推测。该测试文件与 origin/main
逐字节一致(最后修改于 2026-08-28);本 PR 的任何改动都不会触及它。

修复方式

两个在全局临时目录中统计运行目录的测试(ab-drive.test.ts:577 的 harness 版本,以及
:1064/:1083 的真实驱动同类测试)改为在私有 TMPDIR 中统计——沿用本文件已有的
TMPDIR 修改测试的保存/重定向/恢复模式。断言本身不变:运行目录仍必须被清理,前后
计数仍必须一致。在 Windows 上该重定向按既有约定无效,计数行为与之前完全相同。

新增一个见证测试(the sweep count is hermetic — a foreign run dir in the shared temp dir does not move it,仅 POSIX):通过 harness 的 onSession 钩子在运行中途向机器级
共享临时目录植入一个外部 qwen-review-ab-drive- 目录,并断言私有计数不受影响——
以此锁定修复门禁失败的机制。

变异探针(提交前必做):临时移除见证测试中的重定向后,该测试以
AssertionError: expected 1 to be +0 失败——与门禁拒绝签名完全一致——既证明了诊断
出的机制,也证明了见证测试能捕获该机制被移除的回归。恢复重定向后文件重新全绿
(44 通过,9 跳过——跳过的是需要真实 tmux 的 describe;本机没有 tmux,因此同类测试
的修复版本只在有 tmux 的环境中运行,与之前相同)。

反馈处置

  • 可执行区域(Reviews / 行内评论 / Issue 级评论): 本轮依然为空。仅 Critical
    模式仍在生效;没有可执行的评审反馈需要处理。
  • 延后的非 Critical 反馈@qwen-code-ci-bot 的 PR 评论):按仅 Critical 模式保持
    不动——该区域是审计记录,不是本轮的工作内容。
  • 失败检查 review-pr 维持上一轮的判断——自动评审任务本身运行约 6 小时后失败,
    随后工作流的 fallback-comment 任务成功完成;这是评审自动化自身的失败,与本 PR
    代码无关。范围内无法修复;.github/ 属于越界区域。
  • 同轮验证修复: 已处理——本提交修复了门禁给出的确定性拒绝,并通过重跑门禁的
    原始命令至全绿加以验证(见下)。

改动范围说明

本提交触及 packages/cli/src/commands/review/ab-drive.test.ts,该文件不在 PR 自身的
改动范围内。此次扩展并非随意为之:确定性门禁正是在这个测试上拒绝了上一轮提交,而
同轮验证修复指令要求修复该拒绝。改动仅限测试代码、保留原有断言,不新增任何生产
代码面。

验证

  • npx vitest run src/commands/review/ab-drive.test.ts(packages/cli,修复前基线)
    — 43 通过,9 跳过(门禁失败在安静机器上不复现,与竞态诊断一致)
  • 变异探针:移除见证测试中的重定向 → 见证以 expected 1 to be +0 失败(与门禁
    签名完全一致);恢复后 → 全绿
  • npx vitest run src/commands/review/ab-drive.test.ts(packages/cli,修复后)
    — 44 通过,9 跳过(含新增见证测试)
  • npx prettier --check 检查触及文件 — 初次有格式提示,已用 --write 修复,现干净
  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • 门禁原始命令 vitest run --changed origin/main --passWithNoTests --maxWorkers=25% --testTimeout=60000 --hookTimeout=60000 --coverage.enabled=false(packages/cli):
    • 首次本地运行(agent shell 环境)— 31 个失败,均在无关套件
      (config/settings/Footer/docsCommand/ideCommand 等);诊断为 agent 会话的
      SANDBOX / QWEN_HOME / QWEN_CODE_* 环境变量触发了环境检测分支,而门禁的
      干净环境不会
    • 清洗环境后 — 剩 4 个失败,全部为对 /home/github-runner
      EACCES: permission denied(该目录在本沙箱中归 root 所有;这些测试在 $HOME
      内 mkdtemp)
    • 用可写的 HOME=/tmp/fake-home 重跑这 4 个文件 — 939 通过,证实 EACCES 判断
    • 清洗环境 + 可写 HOME779 个文件全部通过,25725 个测试通过,0 失败,
      退出码 0
      —— 拒绝上一轮提交的原始命令现已完全变绿

Deferred non-Critical feedback

Critical-only mode is active: the round counter reached 5 (this window was seeded at round 4 by @qwen-code /takeover from 4, plus 2 change-producing round(s) since). The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:轮次计数已达 5(本窗口由 @qwen-code /takeover from 4 从第 4 轮起算,此后又完成 2 个产生改动的轮次)。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: conflicted with main — resolved in this push. · 与 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/模型 kimi-k3 · CLI 0.23.2

@qqqys

qqqys commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Convergence scope

Takeover remains stopped. This PR is being brought to a bounded acceptance decision for the original Web Shell Goal proposal flow: explicit approval, exactly-once application after the proposing turn ends normally, and no stale execution after cancellation, protective exits, queued user input, Goal changes, or entry into Plan mode.

The Plan-mode transition finding remains in this PR for reproduction and a correctness fix if confirmed: #11360 (comment).

The five other open review threads describe three non-blocking recovery-message problems. They are consolidated in #11569, assigned to the PR author. Their disposition is scope-deferred: tracked outside this PR, not fixed here and not requests for another implementation round here. This includes the three persistence-error mapping comments, the redundant held-Goal notice, and the user/model wording distinction after a Goal changes.

Completion requires the final commit's relevant regressions, build/typecheck, key isolated Web Shell behavior checks, and one bounded correctness review. Hosted CI and review completion are reported separately. Remaining non-blocking polish or review-harness improvements do not expand this patch.

@qqqys

qqqys commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Convergence verification for head 56f115f03f720c02805259bb1ff6c5af61265655.

The Plan-switch defect is fixed and its thread now has reproduction and verification evidence. The five nonblocking threads were explicitly deferred to #11569 (three recovery-message topics). A full paginated readback found 47 total review threads and zero unresolved threads. The scope remains the existing explicit-approval panel, settlement by the owning normal turn, exactly-once application, and safe automatic start. Takeover remains stopped.

Before and after

Before the repair, a real terminal session could approve a Goal, enter Plan while acknowledgement was pending, then create an active Goal and run three automatic turns after acknowledgement was released. The three corresponding deterministic negative tests failed on the pre-fix head, while the ordinary exactly-once control passed.

The final bundle passed these real Web Shell flows through the browser, daemon, ACP child, permission panel, Goal persistence, and automatic-turn execution:

Flow Persisted Goal create count Observed result
Allow, enter Plan, release delayed acknowledgement 0 Plan visibly selected; revoked-approval notice; live Goal null.
Reject 0 No Goal; explicit session reload and live Goal query also returned null.
Allow, cancel, release late acknowledgement 0 Cancellation and not-started notice; Goal remained null.
Allow normally 1 Exactly one Goal and automatic execution; three no-progress fixture turns then produced the expected pause.

The same Plan-switch terminal flow passed on the repair before the final upstream-only merge (622b7b4629): the notice reported revoked approval and /goal returned No Goal set. The Web Shell matrix above and the eight focused core regressions were rerun on final head 56f115f03f.

Local verification

  • Fresh dependency installation; build and bundle; repository typecheck; focused lint, read-only formatting, and diff checks passed.
  • 4,270 tests passed across 17 relevant files: core 1,327; CLI 2,018; bridge 925. The final upstream sync changed session queue cleanup, so 914 affected session tests, the CLI build, bundle, and CLI typecheck were repeated on the pushed head. Counts include that final rerun once.
  • Two self-audit passes and one bounded independent correctness review found no demonstrable blocker within the frozen lifecycle scope. The final upstream queue-cleanup delta was checked separately; it did not alter the Goal repair.
  • Real UI checks used deterministic localhost model responses with separate configuration, runtime, and workspace directories. No channels were enabled. Test services and browser sessions have been stopped.

Validation boundary and hosted gates

The UI evidence covers lifecycle behavior with a controlled model response fixture, not live external-provider behavior or production deployment. No new anonymous/clientless E2E, browser-reconnect check, or Escape-key check is claimed. Local verification and thread resolution do not constitute formal GitHub approval or a merge.

Current-head Qwen Code CI completed successfully, including Ubuntu unit tests, lint/static checks, integration tests, and the downstream Web Shell browser gate and smoke tests. SDK Java, including real-daemon E2E, and TUI parity also passed. The complete current-head readback contains no failed or pending checks; conditional platform and comment-routing jobs were explicitly skipped. GitHub reports MERGEABLE, BLOCKED, and REVIEW_REQUIRED. Formal approval and merge remain outstanding; the stopped takeover was not restarted.

中文说明

本轮已完成范围收敛:Plan 切换缺陷已修复并附证据关闭;五条非阻断线程按三类提示问题移至 #11569,完整分页回读为 47 条、未解决 0 条。接管保持停止,验收范围固定为已有审批面板、原回合正常结算、仅应用一次和安全自动启动。

最终 Web Shell 真实页面及持久化验证:批准后进入 Plan、拒绝、批准后取消均为 0 次 Goal 创建,正常批准为 1 次并开始自动运行。基线曾在进入 Plan 后错误创建 Goal 并自动运行三轮;修复后真实终端显示批准已撤销,/goal 返回没有 Goal。终端证据来自上游最后一次合并前的同一修复,最终 Web Shell 四路径及八个核心回归均在 56f115f 重跑。

本地 17 个相关文件共 4,270 项测试通过,构建、类型、lint、格式、diff 检查通过;上游最后一次队列清理改动在最终 head 重跑了 914 项受影响会话测试及相关构建检查。一轮限定范围正确性审查未发现可证实阻断项。模型回复为本地 fixture,不代表真实模型或生产部署;线程关闭也不等于正式批准或合并。

当前 head 的远端 CI 已通过,包含单测、静态检查、集成测试及后续 Web Shell 浏览器门禁和 Smoke;Java SDK/真实 daemon E2E、TUI 检查也通过。完整回读没有失败或等待中的检查,条件矩阵及评论路由任务按配置跳过。正式评审状态为 REVIEW_REQUIRED,尚未批准或合并,接管保持停止。

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

Approved at head 56f115f0.

Required CI is green at this commit — Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox) and web-shell E2E Smoke completed successfully; only review-pr (the reviewer's own job) is still running. All 47 review threads are resolved, and the one Critical the last deep round left standing is closed — verified against this head, not against the resolution flag.

R5-1 (the skipped-send funnel settling an approved Goal anyway). The three exits that finding named now block settlement before returning, each on the same line that hands back { kind: 'stop', stopReason: 'end_turn' }: the unreliable-drain arm at Session.ts:7059, the empty-drain probe === 'queued' arm at :7082, and the final claim === 'queued' arm at :7159. blockGoalProposalSettlement is now defined twice — once in #handleStopHookLoop (:6560, its original nine exits) and once inside #runStopContinuation (:6920) — which is the right shape, because the hole existed precisely because the funnel was outside the first one's reach. The rest of the earlier Criticals are gone from this diff: the originator-client grant, the provisional-workspace clause and the mid-turn notice were each rewritten in rounds that later reviews re-measured and did not re-list.

I also read what landed after that review rather than assuming a merge is inert. aeadac2afb (Plan mode revoking an approved proposal) hangs off a single new optional field: the pending proposal carries an approvalSignal from a per-Config AbortController, entering PLAN aborts it and leaving the proposal resets the controller, and every consumer treats an aborted signal as applied: false with a message that tells the user to ask for a new draft rather than silently starting the Goal. Two details are right rather than incidental: the abort is guarded by !isDerivedConfig(this), so a derived config cannot revoke the host's controller, and the settlement paths check proposal.approvalSignal?.aborted beside the existing ownsTurn() term instead of replacing it, so the "another turn owns it" case still wins. It brings its own tests (client-goal-plan-switch.test.ts, plus 101 lines in Session.test.ts).

No new Critical found. One question worth an answer, not a gate: the sibling probe === 'unavailable' (:7088-7094) and claim === 'unavailable' (:7166-7172) arms return the identical { kind: 'stop', stopReason: 'end_turn' } without blocking settlement. If "unavailable" can never leave a queued prompt in pendingPromptList, that asymmetry is correct and deserves the one-line comment it currently lacks; if it can, it is the same race R5-1 described by another arm.

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

Reviewed at head 56f115f0 against merge-base 2488d12d. One merge blocker, plus two suggestions and one nice-to-have. Much of this PR checks out — I list what I verified at the end so the objection's scope is clear.

Blocker: the parked approval has two uncoordinated consumers of one single-take latch

Config.takePendingGoalProposal (config.ts:8186-8198) clears pendingGoalProposal unconditionally and returns it to whoever calls first:

this.pendingGoalProposal = undefined;
return proposal;

Two paths call it for the same turn, with different preconditions and different failure reporting.

Consumer A — core, in-stream (client.ts:897-911). Its entire guard set is if (!turnEnded && !signal.aborted) return; … if (!proposal) return; if (signal.aborted) return;. grep -c settlementBlocked packages/core/src/core/client.ts returns 0 — it never consults the block flag the Session sets. It then calls loadGoalRuntime(false) (non-forced), and when that returns undefined it drops an approved proposal with nothing but debugLogger.debug('Dropping an approved Goal proposal: the Goal runtime is unavailable') — no reportFailure, so no GoalSettlementFailed event is emitted on this path.

Consumer B — Session, post-turn (Session.ts:5008#settleGoalProposal). This one force-loads via getGoalRuntimeReady() and emits formatProposeGoalRecoveryNotStarted when it does not own the turn.

They key on the identical string, which is what makes this a race rather than two independent mechanisms: Session.ts:5418 builds this.config.getSessionId() + '########' + this.turn, :5425 stores it as the captured turn's turnKey (the same object :5040 later takes with), and :5456 does promptIdContext.enterWith(promptId) so the stream — and therefore core's settler, which takes with prompt_id (client.ts:3346, :3397 goalTurnKey ??= prompt_id) — sees that exact value.

Two wrong outcomes follow, and they are different failures of the same defect:

  1. Start despite block. Core settles at the in-stream model-stop boundary, so a turn the Session marked settlementBlocked — set at Session.ts:6560 from roughly a dozen call sites covering queued mid-turn user input, todoStopGuardQueuedPromptPriority, guard exhaustion and the stop-hook cap — can still create an active Goal and begin autonomous turns. That is precisely the flow this PR's own test plan says must not start.
  2. Silent total loss. On a first draft, where no Goal exists yet and the runtime is not loaded, core takes the proposal, fails the non-forced load, and drops it with a debug line. Session's settler then receives undefined from the latch and returns without emitting the recovery line. The user clicks Allow, sees the model acknowledge, and no Goal ever exists and no /goal set … recovery is shown — even though the Session path deliberately force-loads to avoid exactly that.

Deterministic coverage cannot catch this today: Session.test.ts mocks the stream, so core's take never runs, and the four browser flows in the test plan (Allow / Reject / cancel-after-Allow / Plan-after-Allow) are all decided by signal.aborted or approvalSignal.aborted, which both consumers honour. The queued-input flow is listed in the test plan but absent from the evidence.

Fix — give the parked proposal one owner. Either skip core's settle when the host settles (if (this.config.getGoalProposalHostSupported?.()) return; before the take at client.ts:900), or record a settlementOwner/blocked flag on PendingGoalProposal that core consults. Independently, make the runtime-unavailable branch at client.ts:903-908 call reportFailure(formatProposeGoalRecoveryFailed(proposal.objective)) (or force-load, as Session does) instead of dropping silently. Then add one test that runs core's settle and Session's settle in sequence against the same real turnKey, for both the blocked-turn and runtime-not-loaded cases.

Suggestion: the turn-error path discards an explicit approval in silence

Session.ts:5038-5044 — the finally calls this.config.takePendingGoalProposal(proposalTurn.turnKey) and ignores the return value, and #settleGoalProposal only runs on the success path (:5008). So when the proposing turn throws (provider error, or the loop-detected turn error at :5035), the user's consent is dropped with no formatProposeGoalRecoveryNotStarted and no GoalSettlementFailed (that channel is wired only inside client.ts). Contrast :5327-5333, where the identical "did not own the turn" outcome does emit the recovery line. Dropping is the correct safety decision; the silence is the defect. Capture the taken proposal in the finally and emit the recovery line when it is non-undefined and the session is not disposed or closing.

Suggestion (security): the capability is trusted on the peer's assertion

acpAgent.ts:14328-14330 sets config.setGoalProposalHostSupported(true) from this.clientCapabilities?._meta?.['qwen.goalProposals'] === true — a value the connecting ACP client supplies in initialize, with no host-side corroboration. That flag is what unlocks the surface: base gated on resolveInteractionMode(this) === 'interactive' alone, and config.ts:9753 is now (resolveInteractionMode(this) === 'interactive' || this.goalProposalHostSupported), so registerLazy(ToolNames.PROPOSE_GOAL, …) runs for a non-interactive session and isGoalProposalAvailable() (:8160-8165) returns true on any turn carrying the approval meta. A non-bridge client — another editor integration, a scripted qwen --acp driver — that asserts the flag can then answer its own confirmation dialog and start autonomous multi-turn Goal work in a session nobody is watching.

To be precise about what is not wrong here: the per-prompt approval meta is correctly gated (privateParentState === 'trusted' at :6314-6320, and the bridge re-injects only for a live attached originator at bridge.ts:10541-10550). So this is the one remaining peer-controlled input in the chain — it is the capability, not the approval, that is trusted on assertion. Corroborate it host-side before enabling the surface: honour qwen.goalProposals only when the connection is a trusted private parent / daemon bridge channel (the same privateParentState === 'trusted' predicate already used at :6316), or require an attached, identified prompt originator at initialize time.

Nice-to-have: two pieces of new public surface

Config.trackSessionRegistration changed from Promise<boolean> to Promise<SessionRegistration> — a breaking signature change on an exported class. In-repo callers were updated, but an out-of-tree JS embedder that still resolves a boolean gets outcome.slot === undefined stored into sessionRegistrySlot and passed to patchSessionRecord(patch, undefined) / unregisterSession(undefined), i.e. a wrong-record or no-record write rather than a compile error. Accepting Promise<boolean | SessionRegistration> and normalising internally would keep it additive. Separately, LlmEventType.GoalSettlementFailed is a new member of the exported ServerLlmStreamEvent union; both in-repo mappers were updated (use-llm-stream.ts:3010-3025, event-adapter.ts:714-718) but an older consumer hits its default branch and drops it silently, losing the only notice that an approved Goal failed to start.

What I verified as sound

The start is authorized by a host-side dialog outcome, not by a wire field or a plan document: ProposeGoalInvocation.shouldConfirmExecute snapshots reviewedGoal = {goalId, revision} | null at confirm time (goal-tools.ts:884-887), execute() re-verifies matchesReviewedGoal before parking (:911-919, and it returns false for undefined, so an older producer fails closed), and applyPendingGoalProposal re-verifies again at settle time (:679-691) while refusing an active Goal. hideAlwaysAllow: true (:892) stops always-allow from converting one consent into standing consent. Idempotence is a real latch, not a boolean: setPendingGoalProposal returns false when one is already parked (config.ts:8173), plus the single take, plus Session's activeGoalProposalTurn === proposalTurn identity guard (:5041, :5324) and the per-turn tool gate (:12239-12249); a second approval in a later turn cannot reuse the old key because the key is the turn's promptId.

I enumerated the release paths and found none that starts twice: normal end (:5001-5008, requiring stopReason === 'end_turn' && !pendingSend.signal.aborted && settlementBlocked !== true), abort/cancel (settlementBlocked set at :8828-8833; core also returns on signal.aborted after taking), Plan-mode entry mid-acknowledgement (setApprovalMode aborts the controller at config.ts:7335-7337, applyPendingGoalProposal refuses on approvalSignal.aborted, and leaving Plan does not revive it because a fresh setPendingGoalProposal mints a new controller), and session close/dispose (ownsTurn() re-checked before and after getGoalRuntimeReady() at :5325-5326/:5337, with a Goal created before the switch paused as GOAL_PAUSE_REASON_SESSION_DISPOSED at :5350-5356).

No new listeners to leak: use-llm-stream.ts +16 is one added case inside the existing per-event switch, event-adapter.ts +5 is one added case in a pure mapper. Turn-start/turn-end races are guarded by identity comparison rather than a boolean, so a new prompt demotes the old turn instead of double-firing it. Approval state is in-memory only, so a restart or channel-worker recycle drops a parked approval — fail-closed, though silent (see the second finding).

Two lifecycle items I could not substantiate to a producer, flagged rather than filed: setTools() is called on turn start (:5428-5436) and in every prompt's finally (:5045-5055) on a LlmClient shared with concurrently dispatched automatic Goal/cron turns, so a non-atomic mutation of the declared-tool list could let an in-flight Goal turn see propose_goal appear or disappear mid-turn (confirming needs reading setTools, outside this pass); and the held-proposal recovery at :4747-4756 does goalQueue.splice(0) plus releaseTurn(key, { requeue: false }) for every queued turn, permanently discarding queued automatic work on each user prompt while a failed pause is held — whether /goal resume re-enqueues those depends on runtime behaviour I did not read.

CI at head: green apart from review-pr. 47 review threads, 0 unresolved. I did not run the suite locally — no node_modules in this checkout.

中文说明(阻塞项摘要)

阻塞项:一个「单次领取」闩锁有两个互不协调的消费者。 takePendingGoalProposalconfig.ts:8186-8198)无条件清空并返回给第一个调用者。core 侧在流内结算(client.ts:897-911),其全部前置条件里没有 settlementBlocked(该文件里 grep 命中数为 0),且用非强制的 loadGoalRuntime(false),失败时只写一行 debugLogger.debug 就丢弃、不发 GoalSettlementFailed;Session 侧在回合后结算(Session.ts:5008),用强制的 getGoalRuntimeReady() 并会发恢复提示。两者的 key 是同一个字符串(Session.ts:5418 构造 → :5425 存入被捕获回合的 turnKey:5456 promptIdContext.enterWith,core 侧以 prompt_id 领取,见 client.ts:3346:3397),所以是竞态而非两套独立机制。

两个后果:① 被阻止的回合仍会启动——Session 标了 settlementBlocked:6560,约十二处调用:回合中排队输入、todoStopGuardQueuedPromptPriority、守卫耗尽、stop-hook 上限)的回合,core 仍可能建出 active Goal 并开始自主回合,而这正是本 PR 测试计划声明不该发生的;② 静默全丢——首个草案(Goal runtime 尚未加载)时 core 领取后丢弃、只留一行 debug,Session 侧随后从闩锁拿到 undefined 直接返回、不发恢复提示:用户点了 Allow、看到模型确认,却既没有 Goal 也没有 /goal set … 恢复行。

现有确定性测试抓不到:Session.test.ts mock 了流,core 的领取根本不执行;测试计划里的四个浏览器流程都由 signal.aborted / approvalSignal.aborted 决定,两条路径都遵守。修法:给这个待处理提案唯一的所有者(core 在宿主会结算时直接跳过领取,或在 PendingGoalProposal 上记一个 owner/blocked 标志供 core 读取),并让 runtime 不可用那一支改为 reportFailure(...) 而不是静默丢弃;再补一个用同一个真实 turnKey 依次跑两条结算路径的测试,覆盖「被阻止的回合」与「runtime 未加载」两种情形。

建议一:回合出错路径(Session.ts:5038-5044)在 finally 里领取后丢弃返回值、不发任何提示,而 :5327-5333 对同样的「未拥有该回合」结果会发恢复行——丢弃是对的,沉默是缺陷。

建议二(安全):「宿主能回答 Goal 提案」这个能力取自对端在 initialize 里自报的 _meta['qwen.goalProposals'] === trueacpAgent.ts:14328-14330),没有任何宿主侧佐证,而它正是解锁开关:base 只看 resolveInteractionMode(this) === 'interactive',现在 config.ts:9753 变成「interactive goalProposalHostSupported」。非 bridge 的 ACP 客户端只要断言该标志就能启用该面并自问自答,从而在无人观看的会话里启动自主多回合 Goal 工作。需要说清楚的是:每回合的审批 meta 是正确受控的(:6314-6320privateParentState === 'trusted';bridge 只为在线且已挂接的发起者重注入,bridge.ts:10541-10550),所以这是授权链上唯一剩下的对端可控输入——被采信的是能力,不是审批。建议用同一个 privateParentState === 'trusted' 谓词在宿主侧佐证后再启用。

可选Config.trackSessionRegistrationPromise<boolean> 改成 Promise<SessionRegistration>,是导出类上的破坏性签名变更(树外 JS 嵌入者会得到 outcome.slot === undefined 并流进 patchSessionRecord/unregisterSession,是运行时错记录而不是编译错误);LlmEventType.GoalSettlementFailed 是导出联合类型的新成员,旧消费者会走 default 分支静默丢掉「已批准的 Goal 启动失败」这一唯一通知。

我也核实了大量正确的部分(授权来自宿主侧对话框结果而非线上字段、reviewedGoal 三处复验、hideAlwaysAllow、幂等闩锁、四条释放路径均不会二次启动、无新增监听器),详见英文部分——反对的范围只限于上面这一条阻塞项。

@qqqys

qqqys commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the current-head single-take settlement blocker from review 5168066176 in 60e2c85e3b.

  • Host-supported ACP/Web Shell sessions now exclusively own approved-proposal settlement; the core settler leaves the latch untouched before any destructive take.
  • The queued-prompt regression now runs the real core settler with the same turn key before Session performs its blocked settlement, and verifies that no Goal is created.
  • Core-owned terminal settlement now emits the existing runnable recovery message when the Goal runtime is unavailable instead of dropping the approval with debug output only.

Local validation passed on Node 22: 54 core Goal tests, 28 ordinary-turn/blocked Session tests, build, repository typecheck, lint, Prettier check, and diff check. A high-concurrency full core run hit unrelated process/Git integration timeouts; all five affected files then passed with one worker and coverage disabled (259 passed, 10 skipped). Current-head hosted checks are running.

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

Re-reviewed at head 60e2c85e, which adds one commit (fix(goal): give ACP proposal settlement one owner, +87 across 3 files) on top of what I requested changes on. The blocker is fixed. Withdrawing my CHANGES_REQUESTED and approving.

The blocker, verified fixed

Both halves of the fix I asked for are in, in the right places:

  • client.ts:899if (this.config.getGoalProposalHostSupported?.()) return; now sits at the top of settlePendingGoalProposal, before const take = this.config.takePendingGoalProposal. So on a host-settled session core never touches the single-take latch at all. That closes both outcomes I filed, and it closes the worse one structurally rather than by patching a condition: core can no longer settle at the in-stream boundary for these sessions, so the settlementBlocked flag is honoured by the only consumer that reads it (Session), and the queued-mid-turn-input / guard-exhausted / stop-hook-cap cases can no longer start an autonomous Goal.
  • client.ts:911 — the runtime-unavailable branch now calls reportFailure(formatProposeGoalRecoveryFailed(proposal.objective)) before returning, so a dropped approval emits GoalSettlementFailed instead of only debugLogger.debug. That was the silent-loss half.
  • The doc comment at :889-890 records the ownership rule ("Host-supported sessions settle after classifying their own protective exits, so core must leave their single-take proposal latch untouched"), which is what stops a future call site from re-introducing the second consumer.

The witnesses are real, not decorative. leaves a host-owned approved proposal for Session settlement flips getGoalProposalHostSupported to true and asserts store.take was not called, loadGoalRuntime was not called, runtime.dispatch was not called, and that the proposal is still retrievable afterwards — so reverting the guard reddens it on all four. reports recovery when the core-owned proposal runtime is unavailable pins the new reportFailure. The shared mock gained getGoalProposalHostSupported: vi.fn(() => false) so the pre-existing core-owned cases keep exercising the old path rather than silently switching sides. This is exactly the coverage gap I named (deterministic tests could not reach core's take because Session.test.ts mocks the stream) and it is now closed from the core side, which is where the take actually lives.

One new coupling worth naming, not blocking: the flag now means "somebody else will settle this", and exactly one site sets it (acpAgent.ts:14328-14330) while exactly one site settles (Session.ts:5008 success path, :5040 error path — between them they always either settle or drop). A future host that set the flag without settling would leave an approval parked forever. The ?. optional call fails toward core settling, which is the safe direction for a Config that predates the method.

Still open from my last pass — all Suggestions, and I am not escalating any of them

  1. The turn-error path (Session.ts:5038-5044) still discards an explicit approval with no user-visible notice, unlike :5327-5333 where the identical "did not own the turn" outcome emits formatProposeGoalRecoveryNotStarted. Dropping is the right safety call; the silence is the gap.
  2. The host capability is still taken from the peer-supplied initialize _meta['qwen.goalProposals'] === true with no host-side corroboration. I am comfortable approving with this open, and want to be explicit about why rather than let it look overlooked: an ACP client that can connect already drives the agent and answers tool confirmations, so enabling the proposal surface is not a meaningful escalation for an already-connected peer. Corroborating it with the privateParentState === 'trusted' predicate already used at :6314-6320 would still be the right hardening, and it is a small change.
  3. The two public-surface notes stand: Config.trackSessionRegistration's Promise<boolean>Promise<SessionRegistration> change is breaking for out-of-tree JS embedders (they get outcome.slot === undefined flowing into patchSessionRecord/unregisterSession rather than a compile error), and LlmEventType.GoalSettlementFailed is a new union member an older consumer drops silently.

None of the three is a merge blocker, and the first two are each a few lines if you want them in this PR rather than a follow-up.

Everything I verified as sound last round is untouched by this commit — the diff is client.ts plus two test files — so the authorization chain (reviewedGoal snapshot and its three re-verifications, hideAlwaysAllow, the idempotence latch, the four release paths, no double-start) still holds as recorded.

CI at 60e2c85e: green apart from review-pr. I did not run the suite locally (no node_modules in this checkout); the two new core cases are the evidence for the fix.

中文说明

在 head 60e2c85e 上复审——它在我提 CHANGES_REQUESTED 的版本之上加了一个提交(fix(goal): give ACP proposal settlement one owner,3 个文件 +87 行)。阻塞项已修好,撤回我的 CHANGES_REQUESTED 并批准。

我要的两半修复都到位、且位置正确:client.ts:899if (this.config.getGoalProposalHostSupported?.()) return; 位于 settlePendingGoalProposal 顶部、在 takePendingGoalProposal 之前,所以宿主结算的会话里 core 根本不碰那个单次领取闩锁——这从结构上(而不是靠补一个条件)关掉了两个后果,尤其是较严重的那个:core 不再能在流内边界结算,于是 settlementBlocked 由唯一会读它的消费者(Session)生效,排队输入/守卫耗尽/stop-hook 上限这些情形不再可能启动自主 Goal。client.ts:911 在 runtime 不可用分支补上了 reportFailure(formatProposeGoalRecoveryFailed(...)),静默丢失那一半也关掉了。:889-890 的注释把所有权规则写了下来,这正是防止将来某个调用点重新引入第二个消费者的东西。

见证测试是实的:leaves a host-owned approved proposal for Session settlement 把开关置 true 后断言 store.take 被调用、loadGoalRuntime 未被调用、runtime.dispatch 未被调用、且提案事后仍可领取——回退守卫会让四条全红;reports recovery when the core-owned proposal runtime is unavailable 钉住新的 reportFailure;共享 mock 补了 getGoalProposalHostSupported: vi.fn(() => false),让既有的 core-owned 用例继续走原路径而不是静默换边。这正好补上了我点出的覆盖缺口(确定性测试到不了 core 的领取,因为 Session.test.ts mock 了流),而且是从 core 侧补的——领取动作就发生在那里。

一处新耦合,仅作记录、不阻塞:这个标志现在意味着「由别人来结算」,而只有一处设置它(acpAgent.ts:14328-14330)、只有一处结算(Session.ts:5008 成功路径与 :5040 错误路径,二者合起来必定要么结算要么丢弃)。将来若有宿主设了标志却不结算,已批准的提案会永远停在那里。?. 的可选调用在方法缺失时倒向 core 结算,对旧 Config 是安全方向。

上一轮其余三条仍开着,都是建议级,我不升级任何一条:① 回合出错路径(Session.ts:5038-5044)仍静默丢弃用户的明确批准,而 :5327-5333 对同样的「未拥有该回合」结果会发恢复提示;② 宿主能力仍取自对端 initialize 自报的 _meta['qwen.goalProposals'],无宿主侧佐证——我明确说明为什么可以带着它批准:能连上的 ACP 客户端本来就能驱动 agent、回答工具确认,所以启用提案面对一个已连接的对端不构成实质提权;用 :6314-6320 已有的 privateParentState === 'trusted' 谓词佐证仍是正确的加固,且改动很小;③ 两处公开面(trackSessionRegistration 的破坏性签名变更、GoalSettlementFailed 新联合成员会被旧消费者静默丢弃)。

上一轮我核实为正确的部分未被此提交触碰(diff 只有 client.ts 加两个测试文件),所以授权链(reviewedGoal 快照与三处复验、hideAlwaysAllow、幂等闩锁、四条释放路径、无二次启动)仍如记录成立。

60e2c85e 上的 CI:除 review-pr 外全绿。我没有在本地跑套件(此 checkout 无 node_modules);修复的证据是那两个新的 core 用例。

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

Approved at head 60e2c85e.

Required CI is green — Test (ubuntu-latest, Node 22.x), Lint & Static, Integration Tests (no-AK, No Sandbox) and web-shell E2E Smoke completed successfully; only review-pr (the reviewer's own job) is still running. All 47 review threads are resolved.

The merge blocker from the maintainer review is fixed at this head, in the form he specified, and I checked the shape rather than the claim:

  • One owner for the single-take latch. LlmClient.settlePendingGoalProposal now returns before touching anything when config.getGoalProposalHostSupported?.() is true (client.ts:899), so in an ACP-hosted session core cannot consume the parked proposal ahead of the Session. That closes both wrong outcomes from one place: a turn the Session marked settlementBlocked can no longer create an active Goal through core's in-stream boundary, and the non-forced loadGoalRuntime(false) in core can no longer be the path that eats the proposal. The early return sits above the take, not below the other guards, which is what makes it total, and it is host-supported-gated, so the interactive CLI/TUI sessions core still owns keep settling exactly as before.
  • The silent-drop branch no longer drops silently. client.ts:908-911 now calls reportFailure(formatProposeGoalRecoveryFailed(proposal.objective)) before returning when the runtime is unavailable, in the path core still owns, with the doc block updated to say why core must leave a host-owned latch untouched.
  • Coverage went where the race actually lives. client-goal.test.ts gains 59 lines and Session.test.ts 24, covering the host-supported stand-down and the failure report — the review's point that a mocked stream hid core's take from the Session suite is answered rather than argued around.

No new Critical found. Two items from that review remain open and I am listing them so they are not read as settled — the reviewing maintainer classified both as Suggestions, and they are his call to triage:

  1. acpAgent.ts:14328 still enables the surface from clientCapabilities._meta['qwen.goalProposals'] === true with no host-side corroboration, so the capability — not the per-prompt approval, which is correctly gated at :6314-6320 and bridge.ts:10541-10550 — is trusted on the peer's assertion. Corroborating it with the same privateParentState === 'trusted' predicate would close it cheaply.
  2. Session.ts:5038-5044 still discards the proposal taken in the finally without emitting formatProposeGoalRecoveryNotStarted, so a proposing turn that throws loses the user's consent quietly. With core now standing down in these sessions, nothing else reports it either, which makes the one-line recovery emit on that path more worth having than it was before.

Both look like small, well-scoped follow-ups rather than reasons to hold this PR, and the second one is the same family as my own earlier note that the probe === 'unavailable' / claim === 'unavailable' arms in the stop-hook funnel behave differently from the three that now block settlement — worth an explicit comment or a test either way.

@qqqys
qqqys added this pull request to the merge queue Sep 10, 2026
Merged via the queue into QwenLM:main with commit 4815941 Sep 10, 2026
67 of 68 checks passed
@chiga0

chiga0 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Post-merge review — PR was merged while this review ran; findings below are for follow-up tracking, not a merge gate.

Verdict: No blocking findings

Tier: Deep (auth/trust boundary, concurrency, lifecycle — yes to all five risk questions)

Scope: all 34 source files in the diff reviewed; docs and test files read as supporting evidence. No local execution (working tree unavailable); runtime-tier findings are therefore unreviewed dimensions.


Cross-check

The blocking finding filed by yiliang114 — two uncoordinated consumers of the single-take proposal latch — was verified fixed at the current head by yiliang114's own re-review (approved at 60e2c85e3b034feafe21e3636b43837ff1cc15ba). Their remaining open items are confirmed below.

Findings

No blocking findings. Non-blocking items below.

Minor — Turn-error path discards an explicit approval silently
packages/cli/src/acp-integration/session/Session.ts finally block (~line 5038-5044)
The finally block calls this.config.takePendingGoalProposal(proposalTurn.turnKey) but ignores the return value, and #settleGoalProposal only runs on the success path, not the catch/error path. When the proposing turn throws (provider error, loop-detection error), the user's consent is silently discarded — no formatProposeGoalRecoveryNotStarted and no GoalSettlementFailed event, unlike the !ownsTurn() path at Session.ts:5325-5333 which does emit the recovery line. Dropping is the correct safety decision; the silence is the gap. (Confirmed from yiliang114's review — I independently missed this path.)

Minor — Host capability trusted on peer assertion
packages/cli/src/acp-integration/acpAgent.ts ~line 14328
config.setGoalProposalHostSupported(true) is called from this.clientCapabilities?._meta?.['qwen.goalProposals'] === true supplied by the connecting ACP client at initialize time, with no host-side corroboration. This is the one remaining peer-controlled input in the authorization chain; every per-prompt approval meta key is properly stripped and re-injected only for privateParentState === 'trusted' connections. An ACP client that can connect already drives the agent and answers tool confirmations, so this does not represent a meaningful privilege escalation for an already-connected peer. Corroborating with the privateParentState === 'trusted' predicate already used at would be a small additional hardening.

Minor — Config.trackSessionRegistration signature breaking change
Return type changed from Promise<boolean> to Promise<SessionRegistration>. In-repo callers updated, but an out-of-tree JS embedder gets outcome.slot === undefined flowing into patchSessionRecord/unregisterSession — a wrong-record write rather than a compile error. Accepting Promise<boolean | SessionRegistration> and normalizing internally would be additive.

Minor — LlmEventType.GoalSettlementFailed new union member
Both in-repo mappers updated (use-llm-stream.ts, event-adapter.ts), but an older out-of-tree consumer hits its default branch and silently drops the only notice that an approved Goal failed to start.


What was checked

Authorization chain:

  • qwen.goalProposalApproval meta key: stripped at bridge entry and only re-injected when originatorClientId is verified, not channelPrompt/continue/restoreAskUserQuestion. Agent also strips and re-injects only for privateParentState === 'trusted'. Double protection confirmed.
  • Plan mode revocation: setPendingGoalProposal creates AbortController → approvalSignal; Plan mode entry calls goalProposalApprovalController?.abort(); applyPendingGoalProposal checks approvalSignal?.aborted first. Tested.
  • Turn ownership: turnKey = promptId (unique per turn); ownsTurn() checks completedNormally, !controller.signal.aborted, pendingPrompt === turn.controller, activeGoalProposalTurn === turn, !disposed, !closing. Settlement runs before releasePendingSend() so pendingPrompt still points to current controller.

Race conditions:

  • ownsTurn() re-checked after async getGoalRuntimeReady() wait. If Plan mode or abort happened during the wait, re-check fails and recovery is shown. Tested.
  • reviewedGoal snapshot at getConfirmation() time re-verified in execute() and again in applyPendingGoalProposal. Prevents applying a proposal to a changed Goal state.

Zombie state:

  • heldGoalProposal entered only when dispatch({action:'pause',...}) fails after goal creation. GoalRuntime subscription auto-clears it when goal state changes. Manual recovery commands shown to user. Correct handling.

Channel exclusion:

  • Session.ts checks !channelTurn before creating goalProposalTurn. Bridge also checks !context?.channelPrompt. Double guard.

Settlement ownership:

  • LlmClient.settlePendingGoalProposal returns early when getGoalProposalHostSupported() is true (commit 60e2c85e). Prevents the two-consumer race on the single-take latch. Confirmed by two tests in client-goal.test.ts.

Not covered: No local execution ran (no working tree). Runtime-tier findings (test validity, mutation probes) are an unreviewed dimension.

Reviewed with AI assistance.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.4.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants