Skip to content

fix(daemon): Bound conditional-close refusal holds - #9820

Merged
yiliang114 merged 3 commits into
QwenLM:mainfrom
doudouOUC:fix/active-work-close-refusal-hold-bound
Aug 24, 2026
Merged

fix(daemon): Bound conditional-close refusal holds#9820
yiliang114 merged 3 commits into
QwenLM:mainfrom
doudouOUC:fix/active-work-close-refusal-hold-bound

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR bounds conditional-close refusal hold adoption to the same 1,024-hold per-Session limit used by active-work snapshots. A response at the limit is still adopted; an oversized response still refuses the close and retains the Session, but leaves the last valid cache intact instead of walking and adopting the oversized list.

It also aligns the active-work design documentation with the implemented conditional-close response shape, all six automatic-cleanup trigger families, and daemon-wide coverage grading after counts are aggregated across runtimes.

Why it's needed

Conditional-close refusals cross the private ACP child boundary. Before this change, the daemon traversed and adopted any returned array even though snapshot ingestion already rejected more than 1,024 holds. A 1,025-hold refusal therefore retained the Session but replaced a fresh empty cache and changed activeWork from false to true. Applying the existing bound before traversal prevents unbounded follow-up work and prevents an oversized refusal from refreshing active-work evidence, without weakening fail-closed Session retention.

The related issue and design documents had also drifted after the shell extension and lifecycle hardening landed, leaving the documented scope, success response, trigger count, and cross-runtime grade inconsistent with current behavior.

Reviewer Test Plan

How to verify

Use a negotiated ACP child with a fresh empty active-work snapshot, detach the last client, and have conditional close refuse with valid holds. With exactly 1,024 holds, confirm the Session remains registered, the returned holds are adopted, activeWork is true, and reporting remains full. With 1,025 holds, confirm the Session still remains registered, the oversized list is not adopted, activeWork remains false from the last valid cache, and reporting remains full.

Confirm the ordinary in-bound refusal path and oversized snapshot rejection still behave unchanged. Review the design text against the child response shape, the detach/prompt/notification/snapshot/attach-rollback/reaper triggers, and the daemon-wide coverage aggregation.

Evidence (Before & After)

N/A — private daemon protocol, lifecycle retention, tests, and documentation only; there is no user-visible or TUI change.

Tested on

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

Environment (optional)

macOS 26.4.1, Node.js 22.22.3, npm 10.9.8, package-level Vitest, TypeScript, ESLint, and Prettier without sandboxing.

Risk & Scope

  • Main risk or tradeoff: An oversized refusal preserves the previous cache, which can age into partial/unknown coverage if no valid snapshot follows. That intentionally fails closed and keeps controllers from treating missing evidence as idle.
  • Not validated / out of scope: No manual CLI E2E was run because the malformed response exists behind the private daemon-to-child ACP boundary; the real NDJSON bridge harness exercises that boundary directly. The full bridge suite passed 778/778, focused child conditional-close tests passed 3/3, and the ACP bridge package build/typecheck, lint, format, and diff checks passed. Full-repository build/typecheck remains blocked by existing workspace dependency artifacts: patched Ink selection types are unavailable in the CLI and the Web Shell cannot resolve its generated daemon React SDK.
  • Breaking changes / migration notes: None. Public health fields, protocol version, persisted formats, and forceful close/kill/shutdown semantics are unchanged.

Linked Issues

Refs #8586

中文说明

本 PR 做了什么

本 PR 将 conditional-close 拒绝响应的 hold 采纳限制为 active-work 快照已经使用的每 Session 1,024 条上限。恰好达到上限的响应仍会被采纳;超出上限的响应仍然拒绝关闭并保留 Session,但不会遍历和采纳超大列表,而是保留最后一次有效缓存。

同时更新 active-work 设计文档,使其与已经实现的 conditional-close 响应结构、全部六类自动清理触发事件,以及跨 runtime 汇总 coverage 计数后再进行 daemon 级分级的行为保持一致。

为什么需要

Conditional-close 拒绝响应来自私有 ACP 子进程边界。变更前,快照入口已经拒绝超过 1,024 条的 holds,但 daemon 仍会遍历并采纳拒绝响应中的任意数组。返回 1,025 条 holds 时,Session 虽然会保留,但新鲜的空缓存会被替换,activeWork 也会从 false 变为 true。在遍历前应用已有上限,可以避免无界的后续处理,并防止超大拒绝响应刷新 active-work 证据,同时不削弱 fail-closed 的 Session 保留语义。

在 shell 范围扩展和生命周期加固合并后,相关 issue 与设计文档也出现了漂移,导致记录的范围、成功响应、触发器数量和跨 runtime 分级与当前行为不一致。

Reviewer 测试计划

验证方式

使用已经协商 active-work 的 ACP 子进程,先发送新鲜的空快照,再 detach 最后一个客户端,并让 conditional close 返回有效 holds 以拒绝关闭。恰好 1,024 条时,确认 Session 仍然注册、返回的 holds 被采纳、activeWork 为 true 且 reporting 保持 full。返回 1,025 条时,确认 Session 同样保留、超大列表不被采纳、activeWork 维持最后一次有效缓存中的 false,且 reporting 保持 full。

同时确认普通的界内拒绝路径和超大快照拒绝行为保持不变。将设计文本与 child 响应结构、detach/prompt/notification/snapshot/attach rollback/reaper 六类触发事件,以及 daemon 级 coverage 汇总实现进行核对。

证据(Before & After)

N/A —— 仅涉及私有 daemon 协议、生命周期保留、测试和文档,不包含用户可见或 TUI 变更。

测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

环境(可选)

macOS 26.4.1、Node.js 22.22.3、npm 10.9.8,未启用 sandbox,运行 package 级 Vitest、TypeScript、ESLint 和 Prettier。

风险与范围

  • 主要风险或取舍:超大拒绝响应会保留此前缓存;如果后续没有有效快照,该缓存可能老化为 partial/unknown coverage。这是有意的 fail-closed 行为,可防止控制器把缺少证据误判为空闲。
  • 未验证 / 范围外:没有运行手工 CLI E2E,因为畸形响应位于私有 daemon-to-child ACP 边界之后;真实 NDJSON bridge harness 已直接覆盖该边界。完整 bridge 测试 778/778 通过,child conditional-close 定向测试 3/3 通过,ACP bridge package 的 build/typecheck、lint、format 和 diff 检查均通过。完整仓库 build/typecheck 仍被现有 workspace 依赖产物阻塞:CLI 无法获得 patched Ink 的 selection 类型,Web Shell 无法解析生成的 daemon React SDK。
  • 破坏性变更 / 迁移说明:无。公开 health 字段、协议版本、持久化格式,以及强制 close/kill/shutdown 语义均未改变。

关联 Issue

Refs #8586

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

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

Copy link
Copy Markdown
Collaborator

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

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

@doudouOUC doudouOUC self-assigned this Aug 23, 2026
@doudouOUC
doudouOUC requested a review from yiliang114 August 23, 2026 16:45
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Re-run note: two things landed since the last gate pass — a feedback commit adding the regression test the review asked for (test-only), and a merge of main. The gate below covers the new head.

Problem: Real, and I re-verified it in the current merge base rather than trusting the prior pass: confirmChildUnheld on main still adopts whatever array a conditional-close refusal hands back (Array.isArray(holds) with no bound), while the snapshot intake in bridgeClient.ts discards any packet with more than ACTIVE_WORK_MAX_SESSION_HOLDS (1,024) holds whole. The two intake sides of the same trust boundary still disagree about the bound on this head — the gap this PR closes is unchanged and still present.

Direction: Aligned — same active-work deep-health effort (#8586), tightening a boundary that effort introduced. No public-contract, auth, or sandbox surface touched: private daemon-to-child protocol, lifecycle retention, tests, and docs only.

Size: Not a core-module path (packages/acp-bridge/src/** plus design docs, single package). 19 production lines (bridge.ts 10, bridgeTypes.ts 9), 87 test lines, 10 doc lines.

Approach: Still minimal. The fix is unchanged from the prior pass and remains the obvious correct one — apply the existing constant before the walk, refuse adoption whole, keep the last valid cache, fail closed on retention. The new commit adds exactly the test the review asked for: a stale non-empty cache must survive an oversized refusal rather than being replaced. The follow-up also declines the "log oversized refusals" suggestion with a reasoned argument (observability-only; safe rate limiting would need per-entry lifecycle state) — a reasonable scoping call, not a gap.

Risk: No elevated risk signals — none of the changed paths are in the revert-correlated set.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

Re-run 说明: 自上次通过门禁以来有两件事——按 review 要求补上回归测试的反馈提交(仅测试),以及合并 main。以下门禁结论针对新的 head。

问题: 真实存在,且我在当前合并基线上重新核实过,而非沿用上次结论:main 上的 confirmChildUnheld 仍然会采纳 conditional-close 拒绝响应返回的任意数组(仅 Array.isArray(holds)、无上限),而快照入口 bridgeClient.ts 会把超过 ACTIVE_WORK_MAX_SESSION_HOLDS(1,024)条 holds 的报文整体丢弃。同一信任边界的两个入口对上限的约定在此 head 上依然不一致——本 PR 要补的缺口没有变化,仍然存在。

方向: 对齐——同属 #8586 的 active-work deep-health 工作,收紧的是该功能引入的边界。不涉及公开契约、auth 或 sandbox:仅私有 daemon-子进程协议、生命周期保留、测试和文档。

规模: 非核心模块路径(packages/acp-bridge/src/** 加设计文档,单一 package)。19 行生产代码(bridge.ts 10 行、bridgeTypes.ts 9 行)、87 行测试、10 行文档。

方案: 仍然是最小改动。修复与上次一致,依旧是显而易见的正确做法——在遍历前应用已有常量、整体拒绝采纳、保留最后一次有效缓存、在保留语义上 fail-closed。新提交恰好补上了 review 要求的测试:陈旧的非空缓存在超大拒绝后必须保留而不是被替换。跟进说明还以合理理由拒绝了"记录超大拒绝日志"的建议(纯可观测性;安全的限流需要 per-entry 生命周期状态)——这是合理的范围取舍,不是缺口。

风险: 无升级风险信号——改动路径均不在与 revert 相关的高风险集合中。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review. The production diff is byte-identical to what the previous pass reviewed — the only change since is the feedback commit, one test-only addition (+43 in bridge.test.ts). I re-confirmed the earlier findings still hold on the new head: the bound check gates adoption before the walk in confirmChildUnheld, the oversized case falls through with the last valid cache untouched, retention stays fail-closed, and the two entry.childHolds write sites (refusal adoption here, snapshot application in applyActiveWorkSnapshot, bounded upstream in bridgeClient.ts) are the only ones — no third intake exists.

The new test is the interesting part, and it is the right one. It seeds a non-empty snapshot cache, ages it past the staleness threshold with fake timers, then refuses the conditional close with 1,025 holds. On the old code the oversized list would have been adopted — activeWork true and reporting full off a fabricated cache; on this code the stale cache survives — activeWork still true but reporting partial. That partial assertion is what distinguishes "retain the last valid cache" from "clear and refresh", which is exactly the gap the review flagged; a test that only asserted Session retention and activeWork would have passed on both variants. Reuses the file's existing helpers, fake timers are installed and restored correctly around the detach. No blockers, no convention violations. The three doc edits were verified line-by-line against the code in the prior pass and are unchanged.

Testing. Evidence carried here: the PR's own CI check results and job log for this commit, fetched via the API, plus static review. This is an unattended CI run — no PR code was built or executed by this review.

The one reservation from the prior run is resolved. The earlier red Test (ubuntu-latest, Node 22.x) was a pre-existing workflow-size precheck failure on main; that baseline was fixed on main since, and on this head the job ran the full suite: step Run tests and generate reports succeeded (npm run test:ci --workspaces --if-present --parallel), every vitest summary in the 3.5 MB job log reports passed — e.g. Test Files 64 passed (64) / Tests 1700 passed | 11 skipped (1711) for the largest suite — and there is not a single failed line anywhere in it. That includes this PR's boundary tests, which previously had only the author's local word behind them. The macOS/Windows test jobs and Integration Tests (CLI, No Sandbox) show skipped by design — they are merge-queue-only jobs (if: github.event_name == 'merge_group'); a skipped named job satisfies the required context. Everything else is green, including Real daemon E2E / Java 11 and web-shell E2E Smoke.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Test (macos-latest, Node 22.x) skipped (merge-queue-only by design)
Test (windows-latest, Node 22.x) skipped (merge-queue-only by design)
Integration Tests (CLI, No Sandbox) skipped (merge-queue-only by design)
precheck-pr / precheck success
Classify PR success
Dependency CVE audit success
Secret scan (TruffleHog) success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
Real daemon E2E / Java 11 success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
SDK Java (ubuntu Java 11/17/21, macOS/Windows Java 21) success
review-pr success

The author-triggered sandboxed run is already in flight: @qwen-code /verify (run 32684265883, still running) will add the one thing CI cannot show — A/B proof that the new 1,024/1,025 boundary tests fail on the base build. CI on this commit now pins the behavior (the tests exist here and pass), so this is corroboration rather than an open gap; the report will land as its own comment. No TUI surface is involved, so /tmux does not apply.

中文说明

代码审查。 生产代码 diff 与上次审查完全一致——此后唯一的变化是反馈提交,一处纯测试新增(bridge.test.ts +43 行)。我在新 head 上重新确认了此前的结论仍然成立:上限检查在 confirmChildUnheld 的遍历之前把关采纳,超大情形直接跳过、最后一次有效缓存保持不动,保留语义仍是 fail-closed;entry.childHolds 的两处写入点(此处的拒绝采纳、applyActiveWorkSnapshot 中的快照应用,后者上游已在 bridgeClient.ts 受限)是仅有的两处——不存在第三个入口。

新测试是重点,而且加得对。它先种下一份非空快照缓存,用假定时器把它推过过期阈值,再让 conditional close 以 1,025 条 holds 拒绝。旧代码会采纳超大列表——activeWork 为 true 且 reporting 基于伪造缓存得出 full;新代码保留陈旧缓存——activeWork 仍为 true 但 reporting 为 partial。正是这个 partial 断言区分了"保留最后一次有效缓存"与"清空并刷新",即 review 指出的缺口;只断言 Session 保留和 activeWork 的测试在两种实现下都会通过。测试复用了文件内已有工具,假定时器在 detach 前后正确安装与还原。无阻塞问题,无规范违规。三处文档修改在上轮已逐行与代码核对,本次未变。

测试。 本节携带的证据:通过 API 获取的本提交自身 CI 检查结果与作业日志,外加静态审查。这是无人值守的 CI 运行,本审查未构建或执行任何 PR 代码。

上次运行唯一的保留意见已消除。此前红色的 Test (ubuntu-latest, Node 22.x)main 上既有的 workflow 体积预检失败;该基线此后已在 main 修复,本 head 上该作业完整运行了测试套件:Run tests and generate reports 步骤成功(npm run test:ci --workspaces --if-present --parallel),3.5 MB 作业日志中每一条 vitest 汇总均为通过——例如最大套件的 Test Files 64 passed (64) / Tests 1700 passed | 11 skipped (1711)——全日志没有一条 failed。这包括了本 PR 的边界测试,此前它们只有作者的本地结果佐证。macOS/Windows 测试作业与 Integration Tests (CLI, No Sandbox) 显示为跳过是设计使然——它们是仅限 merge queue 的作业(if: github.event_name == 'merge_group'),被跳过的具名作业同样满足必需检查上下文。其余全部为绿色,包括 Real daemon E2E / Java 11web-shell E2E Smoke

作者触发的沙箱验证已在运行中:@qwen-code /verify(run 32684265883,仍在进行)将补上 CI 无法展示的一环——A/B 证明新增的 1,024/1,025 边界测试在 base 构建上会失败。本提交的 CI 已经钉住了行为(测试存在于该提交并通过),因此这是佐证而非未闭合的缺口;验证报告会以单独评论发布。不涉及 TUI 界面,因此不适用 /tmux

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — every reservation from the prior pass is now closed: the review feedback was answered with a regression test that pins exactly the right semantics, and this commit's unit suite actually ran in CI and came back green.

Stepping back: the previous pass approved at 4/5 with one named reservation — the unit suite had never executed on the reviewed commit because a pre-existing workflow-size precheck on main killed the job before tests started. That is resolved on this head: the ubuntu job ran the full workspace suite including the 1,024/1,025 boundary tests, all green, zero failed lines in the log. The feedback commit is exactly the shape you want to see: not a reflexive fix, but a test that fails on the wrong variant — adopting the oversized list would report full, retention of the stale cache reports partial — so it genuinely pins the behavior under review instead of passing on both implementations. The reasoned decline of the logging suggestion (observability-only, would need per-entry lifecycle state) is a fair scope call, and the production diff that the prior pass found clean is unchanged. The merge of main brought no conflict-driven edits to the PR's files.

If I had to maintain this in six months: two bounded intake sites agreeing on one named constant, a doc that matches the code, and tests on both sides of the boundary plus the stale-cache edge. Nothing to curse. Approving, pinned to the reviewed commit. The author-triggered @qwen-code /verify run is still in flight and will post its A/B report separately; merge stays gated on the remaining branch-protection approvals either way.

中文说明

整体来看:上一轮以 4/5 批准时留有一个明确的保留意见——由于 main 上既有的 workflow 体积预检在测试启动前就终止了作业,单元测试从未在受审提交上实际运行。本 head 上该问题已解决:ubuntu 作业完整运行了全部 workspace 套件(包括 1,024/1,025 边界测试),全绿,日志中没有任何一条失败。反馈提交正是我们希望看到的形态:不是机械修补,而是一个会在错误变体下失败的测试——采纳超大列表会报 full,保留陈旧缓存则报 partial——因此它真正钉住了受审行为,而不是在两种实现下都能通过。对日志建议的有理有据的拒绝(纯可观测性、需要 per-entry 生命周期状态)是合理的范围取舍;上一轮认定干净的生产 diff 保持不变。合并 main 也未对本 PR 的文件产生任何冲突驱动的改动。

如果六个月后由我来维护:两个受限入口共用一个具名常量、文档与代码一致、边界两侧加陈旧缓存边角都有测试。无可指摘。现予以批准,并固定到所审查的提交。作者触发的 @qwen-code /verify 仍在运行,其 A/B 报告会另行发布;合并无论如何仍受其余分支保护审批门禁约束。

Qwen Code · qwen3.8-max

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

中文说明

已审查。 建议见行内评论。

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

Comment thread packages/acp-bridge/src/bridge.test.ts
Comment thread packages/acp-bridge/src/bridge.ts

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed.

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

  • R1-1 silent oversized-refusal discard lacks a log line — already reported (comment 3839158717)
中文说明

已审查。

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

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

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

doudouOUC commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

[codex] Review follow-up for 201cbc12fd:

Feedback Decision Action
Preserve a stale non-empty cache after an oversized refusal Agreed and fixed Added a regression test that distinguishes last-valid-cache retention from clear-and-refresh; it asserts Session retention, fail-closed activeWork, and partial reporting.
Log oversized refusals Not taking in this PR This is observability-only and safe rate limiting would add per-entry lifecycle state; the bounded refusal remains fail-closed for teardown and the retained cache ages into partial/unknown reporting.

Validation: bridge tests 778/778 passed; ACP bridge build and typecheck passed; targeted lint, format, and diff checks passed. The test engineer independently reproduced the review gap and verified the new test plus the 1,024/1,025 boundary cases (3/3).

CI note: the prior Ubuntu failure was the unrelated workflow-size precheck for .github/workflows/cd-cua-driver.yml, before install or tests. It was not retried on the old SHA because this commit triggers a fresh run.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline.

中文说明

已审查。 建议见行内评论。

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

Comment on lines +849 to +851
expect(bridge.sessionCount).toBe(1);
expect(bridge.activeWork).toBe(true);
expect(reportingGrade(bridge)).toBe('partial');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-1: The new 'retains a stale non-empty cache after an oversized close refusal' test asserts only the post-detach state — session retained, activeWork true, grade 'partial' — and never asserts that the conditional-close round trip actually happened. An oversized refusal mutates nothing observable in confirmChildUnheld (no cache update, no log line — only the timeout branch logs), so the asserted state is byte-identical whether the child was asked or not. A mutation probe confirmed the hole: making entryIsAutoCloseCandidate skip the probe when work is unknown — !childReportsHeldWork(entry) && !childWorkIsUnknown(entry), the exact anti-pattern the gate's design comment forbids ("Skipping on unknown instead would retain such a Session forever") — leaves this test green without any ext call being made, while adding the assertion below flips it to failing (and it stays green against the real code). The helper already records the evidence (handle.agent.extMethodCalls), and this file already asserts on recorded close params elsewhere (~line 644).

Suggested change
expect(bridge.sessionCount).toBe(1);
expect(bridge.activeWork).toBe(true);
expect(reportingGrade(bridge)).toBe('partial');
expect(bridge.sessionCount).toBe(1);
expect(bridge.activeWork).toBe(true);
expect(reportingGrade(bridge)).toBe('partial');
expect(
handle.agent.extMethodCalls.some(
(call) =>
call.method === SERVE_CONTROL_EXT_METHODS.sessionClose &&
call.params?.[ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM] === true,
),
).toBe(true);
中文说明

新增的 'retains a stale non-empty cache after an oversized close refusal' 测试只断言了 detach 之后的状态——Session 保留、activeWork 为 true、grading 为 'partial'——但从未断言 conditional-close 往返确实发生过。超大拒绝在 confirmChildUnheld 中不会产生任何可观察的变化(不更新缓存、不打日志——只有超时分支会打日志),因此无论是否询问了 child,断言的状态都完全相同。变异探测证实了这个缺口:让 entryIsAutoCloseCandidate 在 work 未知时跳过探测——即 !childReportsHeldWork(entry) && !childWorkIsUnknown(entry),正是该门控设计注释明确禁止的反模式("Skipping on unknown instead would retain such a Session forever")——在没有发生任何 ext 调用的情况下测试仍然通过;而补充上面这条断言后,该变异即被捕获(对真实代码则保持通过)。测试辅助工具已经记录了所需证据(handle.agent.extMethodCalls),且本文件其他位置(约 644 行)已有对已记录 close 参数进行断言的先例。

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline.

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

  • oversized-refusal logging on the silent branch (round-3 audit candidate RA2-B) — already reported as R1-1 (comment 3839158717) and declined by the author in reply 3839186549
中文说明

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

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

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

Comment on lines +849 to +851
expect(bridge.sessionCount).toBe(1);
expect(bridge.activeWork).toBe(true);
expect(reportingGrade(bridge)).toBe('partial');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R2-1: The 'retains a stale non-empty cache after an oversized close refusal' test still asserts only the post-detach state — session retained, activeWork true, grade 'partial' — and nothing in it proves the conditional-close round trip actually ran. An oversized refusal changes nothing observable in confirmChildUnheld (no cache update, no log line), so the asserted state is byte-identical whether the child was asked or not. Concretely: if entryIsAutoCloseCandidate were mutated to skip the probe when work is unknown — the exact anti-pattern the gate's design comment forbids — no sessionClose call would ever be made and this test would still pass. Re-verified this round with a scratch-tree probe at d21216c: the mutant leaves the test green, and adding the assertion below flips the mutant to failing while staying green on the real code.

Suggested change
expect(bridge.sessionCount).toBe(1);
expect(bridge.activeWork).toBe(true);
expect(reportingGrade(bridge)).toBe('partial');
expect(bridge.sessionCount).toBe(1);
expect(bridge.activeWork).toBe(true);
expect(reportingGrade(bridge)).toBe('partial');
expect(
handle.agent.extMethodCalls.some(
(call) =>
call.method === SERVE_CONTROL_EXT_METHODS.sessionClose &&
call.params?.[ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM] === true,
),
).toBe(true);
中文说明

'retains a stale non-empty cache after an oversized close refusal' 测试仍然只断言了 detach 之后的状态——Session 保留、activeWork 为 true、grading 为 'partial'——其中没有任何断言能证明 conditional-close 往返确实发生过。超大拒绝在 confirmChildUnheld 中不会产生任何可观察的变化(不更新缓存、不打日志),因此无论是否询问了 child,断言的状态都完全相同。具体来说:如果将 entryIsAutoCloseCandidate 变异为在 work 未知时跳过探测——正是该门控设计注释明确禁止的反模式——sessionClose 调用根本不会发生,而这个测试依然会通过。本轮已在 d21216c 的 scratch-tree 中重新验证:该变异下测试仍然为绿;补充下方断言后,变异即被捕获(对真实代码保持通过)。

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

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

Scripted assertions: 1729 passed · 0 failed · 1729 total

Flakiness gate: ✅ 1 changed test file(s) x 5 identical rounds, no divergence

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

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

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

抖动门:✅ 1 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR 9820 Deep Verification — fix(daemon): Bound conditional-close refusal holds

Verdict: merge-ready — 1729/1729 scripted assertions passed (fail: 0), verified head d21216cbba1437d19952ae0cf560f4aa2ced0606 (merge-ref checkout; base tip 3a1f86d). The central claim is proven load-bearing by a compiled A/B: an oversized (1,025-hold) conditional-close refusal flips activeWork to true and refreshes the cache on base, and is bounded — Session retained, last valid cache intact — on head, while the at-limit (1,024) path, malformed-refusal path, and success path are byte-for-byte behaviorally identical on both arms.

中文摘要

结论:merge-ready。1729/1729 条脚本化断言全部通过(fail: 0),验证对象为 head d21216c(merge-ref 检出,base 为 3a1f86d)。

  • A/B 结论(见「Central claim」表,证据图 01-ab-head-bounded-refusal.png / 02-ab-base-oversized-adopted.png):用真实 NDJSON/ACP 通道驱动编译产物,base 侧对 1,025 条 holds 的拒绝响应会遍历并采纳(activeWork 由 false 翻成 true、缓存被刷新——即 PR 要修的缺陷),head 侧则保留最后一次有效缓存、Session 仍然保留(fail-closed);1,024 条(恰好达界)在两侧同样被采纳;20 万条规模下结论不变(05-scale-200k-refusal-head-vs-base.png)。
  • 测试非空洞:把守卫还原为 base 形态后,两个超限用例按预期红在目标断言上(1025 用例 expected false, received true;stale 用例 expected 'partial', received 'full'),1,024 用例按预期保持绿(它钉住的是保留的采纳路径)(03-vacuity-guard-revert-fails.png)。
  • 门禁:acp-bridge 包全量测试 32 文件 1688/1688 通过(04-acp-bridge-suite-head-1688-green.png;PR 描述中的 778/778 对应 bridge.test.ts 单文件,同样全绿);包级 tsc --noEmit 干净,并用植入的类型错误证明了门禁是活的。
  • 文档:三处文档修正逐条与代码核对一致(子进程响应形状、六类触发、跨 runtime 汇总后再分级)。
  • Findings:无。
  • 未覆盖:逐提交归因(浅克隆仅 3 个提交中的 merge 提交可达,已验证聚合 diff);真实 qwen --acp 子进程产生超限响应的端到端复现(位于私有子进程边界之后,本轮仅复现线缆形状);仓库级 build/typecheck(已知的 cli/web-shell 既有破损,与本 PR 无关)。本轮全部在 Linux(node:22-bookworm 容器)上执行,PR 标注的「Linux 未测试」在本轮范围内未出现任何平台差异。

Scope

  • Central claim: conditional-close refusal holds are bounded at the same 1,024 per-Session limit as snapshots — an oversized refusal still refuses the close and retains the Session, but does not walk/adopt the list and leaves the last valid cache intact; an at-limit (1,024) refusal is still adopted.
  • Secondary claim 1: the three new tests are non-vacuous and pinned to the guard.
  • Secondary claim 2: the design-doc updates match the implementation (response shape, six trigger families, daemon-wide grade-after-sum).

Code under test: one guard in confirmChildUnheld (packages/acp-bridge/src/bridge.ts:3140-3143), shared by every automatic-cleanup trigger, plus comment/doc updates. No protocol, health-field, or dependency changes (git diff HEAD^1..HEAD touches only packages/acp-bridge/src/{bridge,bridgeTypes,bridge.test}.ts and two docs/design/ files).

Central claim — A/B load-bearing proof

Method: base worktree at HEAD^1 (3a1f86d), its packages/acp-bridge compiled with the root TypeScript (tsc -b); head uses the CI-built dist/ at the merge commit. The harness (ab-harness.mjs) drives the real compiled createAcpSessionBridge over a real NDJSON channel (@agentclientprotocol/sdk@0.14.1 ndJsonStream + AgentSideConnection, the repo's internal/testUtils fake child) — mock-free with respect to the unit under test. Each cell: negotiate active-work capability → spawn session → send snapshot → detach last client → child refuses the conditional close with the cell's hold set → observe sessionCount, activeWork, and gradeActiveWorkCoverage(activeWorkCoverage). Control purity asserted below (Methodology).

Cell Scenario Oracle BASE (3a1f86d) HEAD (d21216c) Flip?
A refusal returns 1,024 holds; fresh empty cache sessionCount / activeWork / grade 1 / true / full 1 / true / full no — at-limit adoption preserved
B refusal returns 1,025 holds; fresh empty cache sessionCount / activeWork / grade 1 / true / full (oversized list adopted) 1 / false / full (empty cache intact) yes
C refusal returns 1,025 holds; 1-hold cache aged 46 s past the stale window sessionCount / activeWork / grade 1 / true / full (cache refreshed by oversized refusal) 1 / true / partial (aged cache retained, fail-closed busy) grade: yes
D refusal returns holds: "garbage" (non-array) sessionCount / activeWork / grade 1 / false / full 1 / false / full no — malformed handling unchanged
E child answers {closed: true} (positive control) sessionCount 0 (torn down) 0 (torn down) no — success path unchanged

15 oracle checks per arm, all matching the arm's encoded expectation (the base-arm B/C rows are the predicted-bug control cells; base failing the same way the issue describes is what makes the A/B load-bearing). Witness: 01-ab-head-bounded-refusal.png, 02-ab-base-oversized-adopted.png; raw per-cell JSON in logs/{head,base}-cells.json, transcripts in logs/ab-{head,base}.txt.

Cell C is the sharp one for the PR's second promise ("does not replace the last valid cache"): with the clock advanced past ACTIVE_WORK_STALE_INTERVALS × intervalMs before detach, base's adoption stamps the oversized cache with the advanced time (reads as fresh → full), while head keeps the aged 1-hold cache → partial and still busy. That is exactly the described fail-closed tradeoff: an oversized refusal preserves the previous cache, which ages into partial coverage rather than masquerading as fresh evidence.

Scale sibling (scale-probe.mjs + scale-asserts.mjs, witness 05-scale-200k-refusal-head-vs-base.png): a 200,000-hold refusal — head sessionCount=1, activeWork=false, grade=full, ≈91 ms; base sessionCount=1, activeWork=true (200k-entry Map adopted), ≈127 ms. The behavioral flip holds at scale; the wall-time delta (~36 ms on this shared runner) is modest at 200k because JSON wire encoding of the ~8 MB payload dominates both arms — the mechanism claim ("the daemon no longer walks the oversized structure") is proven by the adoption flip, not by timing.

Boundary consistency with the sibling bound: snapshot ingestion rejects at holds.length > ACTIVE_WORK_MAX_SESSION_HOLDS (bridgeClient.ts:129) and the new refusal guard adopts at holds.length <= ACTIVE_WORK_MAX_SESSION_HOLDS (bridge.ts:3142) — identical acceptance sets (1,024 in, 1,025 out), no off-by-one between the two doors. The snapshot side is pinned by the pre-existing test discards an oversized snapshot whole rather than applying part of it (green in the suite run below), and cell A pins the refusal side at exactly 1,024.

Vacuity check / mutation matrix

The PR introduces one guard. Mutation: restore the exact base form (if (Array.isArray(holds))) in a scratch worktree at the merge commit — an interface-preserving revert — then run the three new tests unchanged (03-vacuity-guard-revert-fails.png):

Guard state Test Outcome Pinned?
reverted bounds close-refusal adoption at 1025 holds REDAssertionError: expected true to be false at expect(bridge.activeWork).toBe(expectedActiveWork) killed
reverted retains a stale non-empty cache after an oversized close refusal REDAssertionError: expected 'full' to be 'partial' at the grade assertion killed
reverted bounds close-refusal adoption at 1024 holds green by construction: 1,024 ≤ 1,024 passes with or without the clause — it pins the retained at-limit adoption, not the guard. Not a coverage gap

Both reds are the intended behavioral assertions (expected-vs-actual values quoted above), not import/compile/fixture breakage. Positive control: the identical command with the guard present (i.e. the head tree) is green — the same file, same filter turns red only when the guard is gone, which also proves the test gate itself is live. No combination row applies (single-hunk defence; the PR has no second guard). The tests' names match their fixtures (counts are MAX and MAX+1; the stale test's fixture is exactly an oversized refusal over an aged non-empty cache).

Findings

None. Probed and clean: at-limit/over-limit boundary, non-array and missing-holds refusals, success path, fail-closed retention (sessionCount stayed 1 in every refusal cell on both arms), reporting grade behavior, 200k-hold scale, snapshot-bound sibling, and the three doc claims below. No injection-style instructions were present in the PR text.

Targeted gates

  • acp-bridge suite at HEAD: npx vitest run in packages/acp-bridge32 files, 1688/1688 tests passed (19.3 s; witness 04-acp-bridge-suite-head-1688-green.png, log logs/head-suite.txt). The PR's quoted 778/778 is the bridge.test.ts file alone; it is green as part of this run, and so are the other 31 files.
  • Package typecheck at HEAD: tsc --noEmit in packages/acp-bridge → clean, exit 0. Liveness proven: a planted const x: number = "not a number" in the same tree was reported by the identical invocation, then removed (clean again).

Doc claims vs code (secondary claim 2)

All three verified against the implementation:

  1. Conditional-close response shapedocs/design/2026-08-06-active-work-health.md now reads { sessionId, closed: true } | { sessionId, closed: false, holds: [...] }. The child (packages/cli/src/acp-integration/acpAgent.ts:10461-10463) returns exactly outcome.closed ? { sessionId, closed: true } : { sessionId, closed: false, holds: outcome.holds }. The old doc's { closed: true, holds: [] } success shape did not match the shipped child (no holds on success) — the fix is accurate.
  2. Six trigger families — code has exactly six: last-client detach (bridge.ts:12543), prompt settled (:9090), notification settled (:11496), attach rollback (:3888), snapshot reporting idle/omitting the Session (:3222, reasons child_idle/child_dropped), and the idle reaper TTL (:3494, idle_timeout). Matches the rewritten "One guard model, every trigger" section.
  3. Daemon-wide coverage gradingpackages/cli/src/serve/routes/health.ts:83-130 sums covered/onNegotiatedChannel/total across all runtimes and then calls gradeActiveWorkCoverage once on the sums, matching "Grade once after summing coverage counts across runtimes" (the old "worst grade across runtimes" wording was wrong).

Not covered

  • Per-commit attribution: the checkout is depth 2 / shallow; git rev-list HEAD^1..HEAD^2 yields only the merge commit d21216c while the metadata lists three commits (6dddc29, 201cbc1, d21216c). The intermediate two are unreachable, so the aggregate HEAD^1..HEAD diff is the verified unit (it contains nothing beyond the files above).
  • End-to-end with a real qwen --acp child producing an oversized refusal: this reproduces the wire shape of the scenario (a real NDJSON/ACP refusal carrying 1,025+ hold objects, parsed by the real daemon path), not the trigger — no code path in the shipped child produces >1,024 holds from collectActiveWorkHolds() under normal operation, and constructing a child that does sits behind the private boundary the PR itself names. The daemon-side handling is fully exercised.
  • Repo-wide build/typecheck: not run; the PR body cites pre-existing workspace breakage (patched Ink selection types for CLI, generated daemon React SDK for Web Shell) unrelated to this diff. The affected package builds and typechecks clean (above).
  • Doc claims were verified by code inspection, not scripted assertions — they are not counted in assertions.json.
  • Windows/macOS: this round ran entirely on Linux (node:22-bookworm, Node v22.23.2). The PR's "Linux not tested" box is therefore covered for everything exercised here; no platform-sensitive code is touched (pure data-structure bound).

Methodology

Environment: CI verify container (node:22-bookworm, Node v22.23.2, npm 10.9.8), merge-ref checkout of refs/pull/9820/merge (HEAD=de70ab2, base HEAD^1=3a1f86d, PR head HEAD^2=d21216c), npm ci + npm run build pre-run at HEAD. A/B: base worktree under tmp/ (removed after capture), its packages/acp-bridge compiled with the root tsc -b; head uses the CI-built dist. Control purity: the PR touches no package.json/package-lock.json and nothing outside packages/acp-bridge + docs/ (git diff --stat HEAD^1..HEAD -- packages/core packages/cli packages/web-shell is empty), and from inside the base worktree readlink -f node_modules/@qwen-code/qwen-code-core resolves to /__w/qwen-code/qwen-code/packages/core (head tree) — byte-identical across both arms, so both cells load the same core; @agentclientprotocol/sdk@0.14.1 is the real shared third-party transport on both sides. (Footnote: the base-tree rebuild printed type errors inside packages/core sources because the worktree lacks the package-local node_modules the head tree has — emit was unaffected, runtime resolution is identical on both arms, and the base dist was verified to contain base code: zero occurrences of the guard, same 68 emitted files.) Harnesses (ab-harness.mjs, scale-probe.mjs, scale-asserts.mjs) import the compiled dist/ of whichever tree they are pointed at and drive it over the repo's real in-memory NDJSON channel; expectations are arm-encoded so predicted base failures count as passing control assertions. Raw logs in logs/; evidence images in evidence/.

Flakiness gate log

rounds=5 files=1 skipped=0
file packages/acp-bridge/src/bridge.test.ts: (cd packages/acp-bridge) npx --no-install vitest run ./src/bridge.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/acp-bridge/src/bridge.test.ts: PPPPP

verdict: pass
summary: 1 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 2 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 3 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 4 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 5 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)

Evidence images

01-ab-head-bounded-refusal

02-ab-base-oversized-adopted

03-vacuity-guard-revert-fails

04-acp-bridge-suite-head-1688-green

05-scale-200k-refusal-head-vs-base

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

Qwen Code · sandboxed verification

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@yiliang114
yiliang114 enabled auto-merge August 24, 2026 04:57

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. Verified at head d21216c:

  • The fix is exactly the bound the body describes: the refused hold set is adopted only when Array.isArray(holds) && holds.length <= ACTIVE_WORK_MAX_SESSION_HOLDS — the same 1,024 per-Session bound the snapshot bounds already use. An oversized refusal skips adoption entirely, so childHolds/childHoldsAt keep the last valid cache instead of walking and adopting the oversized list, while the refusal still returns false and the Session stays registered — fail-closed retention is untouched.
  • The in-bound adoption path keeps its per-entry validation (id/category shape + ACTIVE_WORK_HOLD_CATEGORIES membership), and the constant's doc now states both behaviors (oversized snapshots discarded whole; oversized close refusals retain without replacing the cache).
  • Tests pin both sides of the bound at exactly 1,024 vs 1,025 for the snapshot path with reporting-grade assertions, plus the oversized-refusal retention case.
  • The design-doc alignment (response shape, trigger families, daemon-wide grading) matches the implemented behavior as described.

One non-blocking note: the bot's R2-1 thread on the oversized-refusal test is a fair mutation-sensitivity observation — since an oversized refusal mutates nothing observable, the retained-state assertions can't distinguish "the child was asked and refused oversized" from "the probe never ran". That is test-hardening rather than a defect in this fix (the bound guard itself is a pure function of holds.length, and the snapshot-side parametrized cases pin the bound logic), so I leave it to the author whether to add a round-trip witness (e.g., a spy on the ext-method call or a log line) in a follow-up.

CI at approval time: 20 checks passing, none failing.

@yiliang114
yiliang114 added this pull request to the merge queue Aug 24, 2026
Merged via the queue into QwenLM:main with commit 014b903 Aug 24, 2026
84 checks passed
@doudouOUC
doudouOUC deleted the fix/active-work-close-refusal-hold-bound branch August 24, 2026 05:48
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.22.2.

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.

4 participants