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 仍需独立判断。

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

中文说明

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

@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-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)即可再次接管。

@wenshao

wenshao commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /takeover from 4

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). This window's round counter starts at 4 (the rounds this PR spent in review before takeover), so the Critical-only brake engages after 1 more change-producing round(s) instead of a full fresh 5. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。本窗口轮次计数从 4 起算(即本 PR 托管前已进行的评审轮数),因此再经过 1 个产生改动的轮次即进入 Critical-only,而非重新计满 5 轮。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

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

qwen-code-dev-bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

✅ AutoFix round 7 finished — view run. See this round's report below.

中文说明

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

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

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

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:
Qwen failed during address-review: timeout (7200000ms).

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

中文说明

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

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


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

… 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: 8893cf05a1 — fix(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 统一约束 getFunctionDeclarations、getFunctionDeclarationsFiltered、getDeferredToolSummary 以及调度器的建议列表),以及其余第 1–4 轮的 Suggestion 线程——它们都有作者的修复回复,且第 5 轮评审未再次提出。

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

  • R5-2(rc:3972497760、rc:3972497765、rc:3972497770):unavailable 结算分支把持久化失败映射到一条不可能成功的 /goal set 重试建议;需要一个独立的 kind,在两个宿主中都映射到 PROPOSE_GOAL_UNAVAILABLE_MESSAGE。同一机制横跨三处——必须一起修改,因此作为整体延后。
  • R5-3(rc:3972497775):结算期复核只固定 Goal 身份,从未重新检查回合中途切换到 Plan 模式的情况。
  • R5-4(rc: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

@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 HOME — 779 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 判断
    • 清洗环境 + 可写 HOME — 779 个文件全部通过,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,尚未批准或合并,接管保持停止。

@qqqys
qqqys dismissed a stale review September 10, 2026 12:34

fixed

@qqqys
qqqys enabled auto-merge September 10, 2026 13:31

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

中文说明(阻塞项摘要)

阻塞项:一个「单次领取」闩锁有两个互不协调的消费者。 takePendingGoalProposal(config.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'] === true(acpAgent.ts:14328-14330),没有任何宿主侧佐证,而它正是解锁开关:base 只看 resolveInteractionMode(this) === 'interactive',现在 config.ts:9753 变成「interactive 或 goalProposalHostSupported」。非 bridge 的 ACP 客户端只要断言该标志就能启用该面并自问自答,从而在无人观看的会话里启动自主多回合 Goal 工作。需要说清楚的是:每回合的审批 meta 是正确受控的(:6314-6320 的 privateParentState === 'trusted';bridge 只为在线且已挂接的发起者重注入,bridge.ts:10541-10550),所以这是授权链上唯一剩下的对端可控输入——被采信的是能力,不是审批。建议用同一个 privateParentState === 'trusted' 谓词在宿主侧佐证后再启用。

可选:Config.trackSessionRegistration 从 Promise<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:899 — if (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:899 的 if (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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants