Skip to content

feat(daemon): Track background shells in activeWork - #9042

Merged
doudouOUC merged 1 commit into
QwenLM:mainfrom
doudouOUC:feat/active-work-background-shell
Aug 14, 2026
Merged

feat(daemon): Track background shells in activeWork#9042
doudouOUC merged 1 commit into
QwenLM:mainfrom
doudouOUC:feat/active-work-background-shell

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

This PR extends the existing activeWork retention fact to Session-managed background shells. A Session reports one bounded shell hold while any registered shell is running, while a shell terminal notification is queued, or while that notification is driving its parent continuation. The full local collector remains independent of wire negotiation, while the active-work reporter filters snapshots to the categories negotiated with its daemon peer.

The active-work v1 handshake now negotiates agent, notification, and shell categories explicitly. A new daemon paired with an older v1 child reports partial coverage and disables ordinary automatic cleanup for that Session; a new child paired with an older daemon sends only the legacy categories on the wire but still rejects conditional close locally while shell work exists. Explicit close, kill, shutdown, channel exit, and condemned restore cleanup keep their force semantics.

Why it's needed

A Prompt can start a long-running background shell and settle immediately. Before this change, deep health could then report activePrompts: 0, activeWork: false, and full reporting while the shell task was still running. A restart controller could classify the daemon as idle, close the Session, and prevent the shell terminal notification from reaching the parent continuation.

Using one aggregate shell hold keeps the retention protocol bounded and leaves detailed shell state on the existing task-status surface. Rechecking unfiltered holds after active turns drain also closes the conditional-close window in which an already-running cron or automatic turn can register a shell while teardown is waiting.

Reviewer Test Plan

How to verify

Start a long-running background shell from a Prompt and let the Prompt settle. Confirm that deep health reports activePrompts: 0, activeWork: true, and activeWorkReporting: "full" while the task surface reports the shell as running. Detach the client and confirm that ordinary cleanup preserves the Session. Let the shell finish while blocking its parent continuation and confirm that activeWork stays true through the running-to-queued-to-continuation handoff, then becomes false only after the continuation settles.

Also verify compatibility behavior: a negotiated child that omits shell produces partial reporting and is excluded from ordinary automatic cleanup; a completely unsupported child keeps legacy cleanup; explicit close and kill remain forceful. Unit coverage passed for the shell registry (61 tests), active-work reporter and ACP initialization (405 tests), Session lifecycle and notification handoff (593 tests), and ACP bridge retention and compatibility (577 tests).

Evidence (Before & After)

Before: with a Session-managed sleep 120 task still reported as running, deep health returned activePrompts: 0, activeWork: false, and activeWorkReporting: "full".

After: the same state returns activePrompts: 0, activeWork: true, and activeWorkReporting: "full"; activeWork remains true until the shell terminal continuation settles.

Tested on

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

Environment (optional)

macOS, Node.js 24.12.0, package-level Vitest and TypeScript checks without sandboxing.

Risk & Scope

  • Main risk or tradeoff: Long-running development servers now intentionally keep activeWork: true; negotiated older children are retained rather than automatically cleaned up when their category coverage is incomplete.
  • Not validated / out of scope: Windows and Linux were not tested locally. PID probing, shell stall detection, process recovery, Monitors, workflows, cron, follow-up work, and externally detached processes remain out of scope. Full repository build/typecheck was blocked by the local installed Ink package failing to accept the repository's existing ink+7.0.3 patch, which leaves unrelated CLI text-selection types unavailable; affected core and ACP bridge typechecks, lint, formatting, and all directed behavior tests passed.
  • Breaking changes / migration notes: None. The public health shape, persisted formats, and protocol version remain unchanged; compatibility is negotiated through the existing v1 capability metadata.

Linked Issues

Refs #8586

中文说明

本 PR 的变更

本 PR 将现有 activeWork 保留事实扩展到 Session 管理的后台 shell。当任一已登记 shell 正在运行、shell 终态通知仍在队列中,或该通知正在驱动父 Agent continuation 时,Session 上报一个有界的 shell 聚合 hold。Session 本地的完整 collector 不受 wire 协商影响,只有 active-work reporter 在生成快照时按与 daemon 对端协商的类别进行过滤。

active-work v1 握手现在显式协商 agentnotificationshell 类别。新 daemon 与旧 v1 child 配对时会报告 partial coverage,并禁止该 Session 的普通自动清理;新 child 与旧 daemon 配对时,wire 上只发送旧类别,但本地存在 shell 工作时仍会拒绝 conditional close。显式 close、kill、shutdown、channel exit 和 condemned restore cleanup 继续保持强制语义。

为什么需要

Prompt 可以启动长时间运行的后台 shell 后立即结束。变更前,即使 shell 任务仍在运行,deep health 也可能返回 activePrompts: 0activeWork: false 和 full reporting。重启控制器可能因此把 daemon 误判为空闲、关闭 Session,并阻止 shell 终态通知到达父 Agent continuation。

单一 shell 聚合 hold 能让保留协议保持有界,详细 shell 状态仍由现有任务状态接口提供。在 active turn 排空后再次检查未过滤 hold,也关闭了 conditional-close 窗口:已经运行的 cron 或自动 turn 可能在 teardown 等待期间登记新的 shell。

Reviewer 测试计划

验证方式

从 Prompt 启动一个长时间运行的后台 shell,并让 Prompt 结束。确认任务接口仍显示 shell 运行时,deep health 返回 activePrompts: 0activeWork: trueactiveWorkReporting: "full"。detach 客户端并确认普通清理仍保留 Session。让 shell 结束但阻塞父 continuation,确认 activeWork 在 running、queued 和 continuation 的整个交接过程中始终为 true,并且只在 continuation 完成后变为 false。

同时验证兼容行为:协商后缺少 shell 的 child 产生 partial reporting,并且不参与普通自动清理;完全不支持 active-work 的 child 继续使用旧清理行为;显式 close 和 kill 仍为强制操作。shell registry 61 项、active-work reporter 与 ACP 初始化 405 项、Session 生命周期与通知交接 593 项,以及 ACP bridge 保留与兼容 577 项单元测试均已通过。

证据(Before & After)

Before:Session 管理的 sleep 120 任务仍显示 running 时,deep health 返回 activePrompts: 0activeWork: falseactiveWorkReporting: "full"

After:同一状态返回 activePrompts: 0activeWork: trueactiveWorkReporting: "full";直到 shell 终态 continuation 完成,activeWork 都保持 true。

测试平台

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

环境(可选)

macOS、Node.js 24.12.0,未启用 sandbox,运行 package 级 Vitest 和 TypeScript 检查。

风险与范围

  • 主要风险或取舍:长时间运行的开发服务器现在会按预期保持 activeWork: true;已协商但类别覆盖不完整的旧 child 会被保留,而不会被普通自动清理。
  • 未验证 / 范围外:未在本地测试 Windows 和 Linux。PID 探测、shell 卡死检测、进程恢复、Monitor、workflow、cron、follow-up 工作和外部脱离进程不在本 PR 范围内。完整仓库 build/typecheck 被本地已安装 Ink 包无法应用仓库现有 ink+7.0.3 补丁所阻断,导致与本 PR 无关的 CLI 文本选择类型缺失;受影响的 core 与 ACP bridge typecheck、lint、格式检查及所有定向行为测试均通过。
  • 破坏性变更 / 迁移说明:无。公开 health 结构、持久化格式和协议版本均不变;兼容性通过现有 v1 capability metadata 进行协商。

关联 Issue

Refs #8586

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

Copy link
Copy Markdown
Collaborator Author

E2E report

Baseline

On macOS with the released global qwen 0.21.10, I started qwen serve --bare --safe-mode --no-web --port 0, created a Session, and prompted one sleep 120 shell with is_background=true. After approving the shell and letting the Prompt settle, /session/:id/tasks showed the shell as running, while /health?deep=1 returned activePrompts: 0, activeWork: false, and activeWorkReporting: "full".

After

The same flow against the local build kept the running shell visible and returned activePrompts: 0, activeWork: true, and activeWorkReporting: "full". SIGINT still completed the existing forced daemon shutdown.

Automated verification

  • Background shell registry: 61 tests passed.
  • Active-work reporter and ACP initialization: 405 tests passed.
  • Session hold and notification lifecycle: 593 tests passed.
  • ACP bridge retention and compatibility: 577 tests passed.
  • Prettier and ESLint passed for every changed file.
  • Core and ACP bridge typechecks passed.

The full repository build/typecheck could not complete in this checkout because the local installed Ink package rejected the repository's existing ink+7.0.3 patch, leaving unrelated CLI text-selection types unavailable. No reported build error referenced a file changed by this PR.

@doudouOUC
doudouOUC marked this pull request as ready for review August 13, 2026 06:03
@doudouOUC
doudouOUC enabled auto-merge August 13, 2026 06:03
@doudouOUC doudouOUC self-assigned this Aug 13, 2026
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this is the documented background-shell follow-up to the activeWork umbrella (#8586). This is a re-run on unchanged code (fe9e0b9f3), triggered after the deep /review pass and the maintainer's independent A/B report landed; the gate outcome is unchanged, and the re-run's substance is in the Stage 2 comment.

Template looks good ✓

Problem: observed, not theoretical. Layer-1 activeWork deliberately excluded background shells because there was no signal for them, and the gap is confirmed in code — the base Session.collectActiveWorkHolds() has no shell term, so a Session with a running background shell reports activeWork: false to deep health while the task surface still shows it running. The E2E report in this thread reproduces it on released qwen 0.21.10, and the maintainer's independent real-stack A/B (also in this thread) reproduces the harm end to end: base build reaps a detached Session 2s after the client detaches and kills the running sleep 120 ~118s early; head build retains it until the continuation settles.

Direction: aligned. This is the extension path the umbrella issue explicitly designed for — holds carry a category so the scope widens by adding data rather than changing what the boolean means — and the widening is documented in the #8586 thread by the issue author. The public health shape and protocol version stay unchanged; compatibility is negotiated per channel, not assumed.

Size: core paths touched (packages/core/src/services/backgroundShellRegistry.ts, plus cross-package changes in acp-bridge and cli). Production logic ≈ 143 lines vs ≈ 563 test lines vs ≈ 72 docs lines. feat type, well under the 500-line maintainer-awareness threshold.

Approach: the scope feels right. One bounded aggregate hold instead of a per-shell roster keeps the protocol inside its 1024-holds cap; filtering happens only at wire serialization so the local conditional-close check stays truthful against an older daemon; re-reading the collector after active turns drain closes the window where an already-running cron turn registers a shell mid-teardown. I didn't spot unrelated changes or drive-by edits. What the re-run does flag is the ordering inside that conditional-close path — the drain's destructive steps run before the read that can refuse the close — which is a Stage 2 finding, not a gate objection.

Risk: packages/cli/src/acp-integration/** matches this repo's revert-correlated high-risk paths, so this gets full review depth and CI evidence before any approval. One behavioral consequence to be aware of: a long-running dev server now pins activeWork: true and ordinary automatic cleanup retains its Session until the shell exits — stated as intended in the PR and the design doc, but worth a maintainer's awareness.

Moving on to code review. 🔍

中文说明

感谢贡献——这是 activeWork 伞形 issue(#8586)中已记录在案的后台 shell 后续工作。本次是在代码未变(fe9e0b9f3)情况下的重跑,由深度 /review 与维护者独立 A/B 报告落地后触发;门禁结论不变,重跑的实质内容在 Stage 2 评论中。

模板完整 ✓

问题:已观测到,不是理论问题。Layer-1 的 activeWork 当时因为没有对应信号而刻意排除了后台 shell,代码也证实了这个缺口——base 版本的 Session.collectActiveWorkHolds() 没有 shell 项,因此有后台 shell 在运行的 Session 会向 deep health 上报 activeWork: false,而任务接口却仍显示它在运行。本线程中的 E2E 报告在已发布的 qwen 0.21.10 上复现了该问题;维护者的独立真实栈 A/B(同在本线程)端到端复现了危害:base 构建在客户端断开 2 秒后即收割 detached Session、提前约 118 秒杀掉运行中的 sleep 120,head 构建则保留会话直到 continuation 结束。

方向:对齐。这正是伞形 issue 明确预留的扩展路径——hold 携带 category,范围通过增加数据而非改变布尔语义来扩展——且这次范围扩展已由 issue 作者记录在 #8586 讨论中。公开 health 结构和协议版本不变;兼容性按 channel 协商,而不是默认假设。

规模:触及核心路径(packages/core/src/services/backgroundShellRegistry.ts,以及 acp-bridgecli 的跨包改动)。生产逻辑约 143 行,测试约 563 行,文档约 72 行。feat 类型,远低于 500 行的维护者关注阈值。

方案:范围合理。用一个有界的聚合 hold 代替逐 shell 清单,让协议保持在 1024 hold 上限内;过滤只发生在 wire 序列化处,使本地 conditional-close 检查在面对旧 daemon 时仍然真实;在 active turn 排空后重读 collector,关闭了已运行的 cron turn 在 teardown 期间注册 shell 的窗口。未发现无关改动或顺手修改。重跑真正标记的是该 conditional-close 路径内部的顺序问题——drain 的破坏性步骤跑在了可以拒绝关闭的读取之前——这是 Stage 2 的发现,不是门禁异议。

风险:packages/cli/src/acp-integration/** 命中本仓库与 revert 相关的高风险路径,因此批准前会执行完整 review 深度并要求 CI 证据。一个需要知晓的行为后果:长时间运行的 dev server 现在会保持 activeWork: true,普通自动清理会保留其 Session 直到 shell 退出——PR 和设计文档中均声明这是预期行为,但值得维护者关注。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review (re-run)

This pass re-reviewed the code with the deep /review findings in hand. Two corrections to my earlier pass first, because they matter more than the new notes: I previously endorsed the post-drain re-check's placement without noticing the drain is destructive, and I previously wrote "explicit close/kill/shutdown stay forceful" without checking the deferred spawn-owner kill path. Both gaps are real, and the first one is blocking.

Blocking — the reject-after-drain outcome can retain a Session whose queued work the drain already destroyed. The ordering in closeStoredSession is: early hold read (pre-existing, acpAgent.ts:4354) → abort generation controllers → drain via cancelPendingPrompt() + waitForActiveTurnsToSettle() (acpAgent.ts:4368-4382) → the post-drain re-read this PR adds (acpAgent.ts:4389-4394) → destructive finalize/close, with the finally releasing the close gate on refusal. But cancelPendingPrompt() (Session.ts:3352-3432) has already, unconditionally: aborted the running turn, suspended the todo stop guard, spliced and aborted every queued goal turn (and paused the goal runtime), cleared cronQueue, cleared notificationQueue, and stopped the cron scheduler. So when an out-of-scope cron turn registers a background shell while being aborted — the exact race the new re-read's own comment describes ("Existing out-of-scope work such as a cron turn may have registered a background shell while it drained") — the re-read correctly refuses the close, and the Session is retained with its scheduler stopped, its cron/@wakeup/goal queues empty, and nothing logged. A detached Session never recovers: the scheduler's only restart sites are Session creation (Session.ts:2876-2883) and prompt()'s finally (Session.ts:3706-3711), and cron turns run through #executeCronPromptInner (Session.ts:6689), not prompt() — so no turn arrives to restart it. The gate-release re-drain re-runs three empty queues. Pre-PR this destruction was always followed by teardown (the early read was the only check, and its old comment said a hold "cannot appear between this read and the teardown"), so the destroyed-but-retained state is new with this diff: the fix introduced it. The drain-timeout variant reaches the same state. The direction suggested in the review finding is the right one — on the onlyIfUnheld path, defer the destructive steps until after authorization (bounded natural settlement, re-read, then aborts + cancelPendingPrompt() only when both reads came back empty), or restore the scheduler and queue state on the rejection path.

Deferred spawn-owner kills are silently suspended for incomplete children. killSession(..., {requireZeroAttaches: true}) sets the spawnOwnerWantedKill tombstone when another client is still attached (bridge.ts:10623-10626); the tombstone's only resolution sites are maybeCloseIdleSession (bridge.ts:2284) and the reaper backstop (bridge.ts:2735), and both now sit behind entryIsAutoCloseCandidate (bridge.ts:2219), whose new capability guard (bridge.ts:2238-2249) returns false for a negotiated-but-incomplete child before the tombstone branch can run. Nothing else ever clears it, so an explicitly requested kill for such a Session never executes — even with no shell running — contradicting the PR's "kill … keep their force semantics". killSession itself consults no child predicate (kill is force everywhere else), so the guard protects nothing on this path; handle spawnOwnerWantedKill && attachCount === 0 ahead of the capability check.

The finding's four suggestions also check out statically, and they're worth taking in the same revision: the "covers every required category" predicate is now hand-inlined twice (cleanup guard bridge.ts:2244-2245 vs health grading bridge.ts:6750-6751 — extract one into bridgeTypes.ts); the category-intersection parse is duplicated across the two handshake endpoints (bridge.ts:3581-3585 vs acpAgent.ts:4689-4692) while the interval half of the same handshake already shares clampActiveWorkIntervalMs; the !owner.isQuarantined escape hatch has no test combining quarantine with incomplete capability (the existing quarantine test at bridge.test.ts:7578 doesn't reach the new guard — the finding's deletion-mutant claim is the /review run's own empirical probe, not re-run here, since this review executes no PR code); and the early-rejection test asserts only the response payload, so deleting the first read survives the whole suite — the two proposed not.toHaveBeenCalled() assertions close that.

What the re-run re-confirmed as solid (unchanged from the first pass): the derived aggregate hold with no ledger to leak, wire-only filtering with an unfiltered local close check, the fail-closed compatibility matrix in every pairing, identity-safe callback retraction, and the queued-notification → continuation handoff. The design is right; the conditional-close ordering is what needs fixing.

sequenceDiagram
    participant P1 as Prompt turn
    participant P2 as Shell registry
    participant P3 as Session hold collector
    participant P4 as Active-work reporter
    participant P5 as Daemon bridge
    P1->>P2: register background shell
    P2->>P3: status change, running
    P3->>P4: notifyChanged
    P4->>P5: snapshot with shell hold
    Note over P3: hold true while running or queued or in continuation
    P2->>P3: completion notification queued first
    P2->>P3: then entry flips to terminal
    P3->>P4: hold stays true via the queue term
    P3->>P3: drain marks continuation active
    P3->>P4: hold released only after the continuation settles
Loading
Files changed (16 of 16 shown)
File What changed
docs/design/2026-08-06-active-work-health.md Layer-1 design doc updated for the shell category, the incomplete state, and the post-drain re-read
docs/design/2026-08-13-active-work-background-shell.md New design doc for this change
docs/developers/qwen-serve-protocol.md Deep-health field documentation widened to cover shells
packages/acp-bridge/src/bridge.ts Retains Sessions of negotiated-but-incomplete children in the shared auto-close guard; advertises categories in initialize
packages/acp-bridge/src/bridgeTypes.ts Adds the shell category and the legacy category baseline constant
packages/acp-bridge/src/bridge.test.ts Incomplete-child retention, aggregate shell hold, force-close semantics, condemned restore with a legacy child
packages/cli/src/acp-integration/acpAgent.ts Negotiates the category intersection; re-checks holds after active turns drain
packages/cli/src/acp-integration/acpAgent.test.ts Negotiation, pre-category baseline, post-drain re-check, flush-before-response ordering
packages/cli/src/acp-integration/active-work-reporter.ts Filters the wire snapshot to negotiated categories
packages/cli/src/acp-integration/active-work-reporter.test.ts Wire filtering coverage
packages/cli/src/acp-integration/session/Session.ts Aggregate shell hold, identity-safe status callback, continuation flag across the drain loop
packages/cli/src/acp-integration/session/Session.test.ts Aggregate cardinality, queue-to-continuation handoff, failure and cancel release
packages/cli/src/acp-integration/session/Session.review-lease.test.ts Mocks the new registry methods
packages/cli/src/acp-integration/session/Session.worktree.test.ts Mocks the new registry methods
packages/core/src/services/backgroundShellRegistry.ts Identity-safe clearStatusChangeCallback; doc comments updated
packages/core/src/services/backgroundShellRegistry.test.ts Callback identity and cancel releases the running state

Testing

Unattended CI run — no PR code was executed here; the evidence below is the PR's own CI read through the API, plus verification reports posted in this thread and attributed to their authors.

All checks on the reviewed commit have completed — nothing pending, nothing red. The Linux full profile (fork PR) is green: Test (ubuntu-latest, Node 22.x) (lint, format, typecheck, unit suite), precheck-pr / precheck, Desktop Shell on both OSes, Real daemon E2E, web-shell E2E Smoke, and the Java SDK matrix. The macOS/Windows Test legs and Integration Tests (CLI, No Sandbox) are skipped by design — ci.yml gates them to the merge queue. A green suite here proves the tests pass; it does not pin the behavioural claims, because none of the PR's own tests exercise a cron turn racing the conditional-close drain.

Two independent real-stack reports are on record, attributed to their authors (not re-run here): the maintainer's A/B (fe9e0b9f3 vs merge-base, real qwen serve daemon, mocked model, judged on deep health, task surface, continuation delivery, daemon log, and the real shell PID) reproduced every behavioural claim — lifecycle hold, reaper harm, both cross-version pairings, force semantics — and found no regression; the author's earlier E2E report reproduced the baseline gap on released 0.21.10. Neither run covered the blocking scenario above: a cron turn registering a shell while a conditional close drains it.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Test (macos-latest, Node 22.x) skipped (merge-queue only)
Test (windows-latest, Node 22.x) skipped (merge-queue only)
precheck-pr / precheck success
Classify PR 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
Integration Tests (CLI, No Sandbox) skipped
SDK Java (ubuntu/macos/windows matrix) success

Sandboxed verification would settle what's still open after the fix: @qwen-code /verify — that the conditional-close path no longer destroys queued work or stops the scheduler when it refuses a close (a cron turn registering a shell mid-drain) is not observable from the green suite, and neither existing real-stack run covered it. This is a sponsored run (the author lacks write access, so /tmux is unavailable): a maintainer's comment approves the head it runs against, with the pre-execution risk screen and workspace wipe — read the resulting report with the same skepticism as the fork's own CI logs.

中文说明

代码审查(重跑)

本次带着深度 /review 的发现重新审查了代码。先更正我上一轮的两处结论,因为它们比新备注更重要:我之前认可了 drain 后复查的位置,却没有注意到 drain 本身是破坏性的;我之前写"显式 close/kill/shutdown 保持强制语义",却没有核对延迟的 spawn-owner kill 路径。这两个缺口都是真实的,第一个是阻塞性的。

阻塞项——drain 后拒绝关闭的结果可能保留一个其排队工作已被 drain 摧毁的 Session。 closeStoredSession 的顺序是:提前 hold 读取(既有,acpAgent.ts:4354)→ 中止 generation controllers → 经 cancelPendingPrompt() + waitForActiveTurnsToSettle() drain(acpAgent.ts:4368-4382)→ 本 PR 新增的 drain 后复查(acpAgent.ts:4389-4394)→ 破坏性的 finalize/close,finally 在拒绝时释放 close gate。但 cancelPendingPrompt()(Session.ts:3352-3432)在此之前已经无条件地:中止运行中的 turn、挂起 todo stop guard、splice 并中止所有排队 goal turns(并 pause goal runtime)、清空 cronQueue、清空 notificationQueue、停止 cron scheduler。因此当一个范围外的 cron turn 在被中止的过程中登记了后台 shell——正是新复查自身注释所描述的竞态("Existing out-of-scope work such as a cron turn may have registered a background shell while it drained")——复查正确地拒绝关闭,Session 被保留,但其 scheduler 已停、cron/@wakeup/goal 队列已空,且没有任何日志。detached Session 无法恢复:scheduler 的重启点只有 Session 创建(Session.ts:2876-2883)和 prompt()finally(Session.ts:3706-3711),而 cron turn 走 #executeCronPromptInner(Session.ts:6689)、不走 prompt()——所以不会有任何 turn 来重启它。gate 释放时的重排只是重跑三个已空的队列。PR 之前这种破坏总是紧随 teardown(提前读取是唯一的检查,其旧注释说 hold "cannot appear between this read and the teardown"),因此"被破坏但被保留"的状态是本 diff 新引入的:修复本身制造了它。drain 超时变体到达同样的状态。review 发现中建议的方向是对的——在 onlyIfUnheld 路径上把破坏性步骤推迟到授权之后(有界的自然 settle、复查、仅当两次读取都为空时才执行 aborts + cancelPendingPrompt()),或在拒绝路径上恢复 scheduler 与队列状态。

对类别不完整的 child,延迟的 spawn-owner kill 被静默挂起。 killSession(..., {requireZeroAttaches: true}) 在仍有其他 client attached 时设置 spawnOwnerWantedKill tombstone(bridge.ts:10623-10626);该 tombstone 唯一的解决点是 maybeCloseIdleSession(bridge.ts:2284)和 reaper 兜底(bridge.ts:2735),而两者现在都位于 entryIsAutoCloseCandidate(bridge.ts:2219)之后,其新增的能力保护(bridge.ts:2238-2249)会在 tombstone 分支运行前对协商过但类别不完整的 child 返回 false。没有其他任何地方清除它,因此对这类 Session 的显式 kill 请求永远不会执行——即使没有任何 shell 在运行——与 PR 声称的 "kill … keep their force semantics" 矛盾。killSession 本身不询问任何 child 谓词(kill 在其他所有地方都是强制的),所以该保护在这条路径上没有保护任何东西;应把 spawnOwnerWantedKill && attachCount === 0 放在能力检查之前处理。

四个 Suggestion 经静态核对也都成立,值得在同一轮修订中一并处理:"覆盖全部必需类别"谓词现已手工内联两次(清理保护 bridge.ts:2244-2245 vs 健康分级 bridge.ts:6750-6751——抽取到 bridgeTypes.ts);类别交集解析在握手两端重复(bridge.ts:3581-3585 vs acpAgent.ts:4689-4692),而同一握手的 interval 一半已共享 clampActiveWorkIntervalMs!owner.isQuarantined 豁免分支没有把 quarantine 与不完整能力组合起来的测试(既有 quarantine 测试 bridge.test.ts:7578 到不了新保护——该发现中的删行变异结论来自 /review 运行自己的实证探测,本审查不执行 PR 代码故未重跑);提前拒绝测试只断言响应载荷,因此删除第一次读取后整个套件仍然通过——补上建议的两条 not.toHaveBeenCalled() 断言即可封堵。

重跑再次确认仍然扎实的部分(与首轮一致):派生的聚合 hold、没有可泄漏的台账、只在 wire 处过滤而本地关闭检查保持未过滤、所有配对下 fail-closed 的兼容矩阵、身份安全的回调撤回、排队通知 → continuation 的交接。设计是对的;需要修的是 conditional-close 的顺序。

测试

无人值守 CI 运行——此处未执行任何 PR 代码;以下证据是通过 API 读取的 PR 自身 CI,以及本线程中署名作者的验证报告。

被审提交上的所有 check 均已完成——无 pending、无红灯。Linux 全 profile(fork PR)全绿:Test (ubuntu-latest, Node 22.x)(lint、format、typecheck、单测)、precheck-pr / precheck、双平台 Desktop Shell、Real daemon E2E、web-shell E2E Smoke 以及 Java SDK 矩阵。macOS/Windows Test 腿与 Integration Tests (CLI, No Sandbox) 为设计性 skipped——ci.yml 将其限定在合并队列。全绿只证明测试通过,不能钉住行为声称:PR 自己的测试没有任何一个让 cron turn 与 conditional-close drain 竞态。

两份独立的真实栈报告记录在案,归属于其作者(未在此重跑):维护者的 A/B(fe9e0b9f3 vs merge-base,真实 qwen serve daemon、mock 模型,以 deep health、任务接口、continuation 送达、daemon 日志与真实 shell PID 为判据)复现了全部行为声称——生命周期 hold、reaper 危害、两种跨版本配对、强制语义——未发现回归;作者更早的 E2E 报告在已发布的 0.21.10 上复现了基线缺口。两次运行都未覆盖上面的阻塞场景:cron turn 在 conditional close drain 期间注册 shell。

沙箱验证可以补上修复后仍开放的缺口:@qwen-code /verify —— conditional-close 路径在拒绝关闭时不再摧毁排队工作、不再停止 scheduler(cron turn 在 drain 中注册 shell),这一点从全绿套件中观察不到,现有两次真实栈运行也未覆盖。这是一次 sponsored run(作者无写权限,/tmux 不可用):维护者评论即批准其对应的 head,运行带执行前风险筛查与工作区擦除——请以其 CI 日志同等的怀疑态度阅读产出的报告。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 2/5 — the design is genuinely good and the maintainer's real-stack A/B settled every behavioural claim the PR makes; but the re-run confirmed a blocking defect those runs didn't reach — the new reject-after-drain path can retain a Session whose queued work the drain has already destroyed — so I can't stand behind approval as-is.

Stepping back: my independent proposal before reading the diff was the same shape the PR implements — one derived aggregate hold, wire-only filtering, negotiated categories, fail-closed retention — and I still think that shape is right; the PR's version is better than mine at the edges I did check. What I missed in the first pass, and what this re-run verified line by line, is that the conditional-close fix is ordered destructively: cancelPendingPrompt() clears the cron/goal/notification queues and stops the cron scheduler before the read that can refuse the close, and a retained detached Session has no path to restart any of it. The PR's own comment names the race; the fix just catches it on the wrong side of the destruction. That's silent, permanent loss of scheduled work in exactly the retention domain this PR exists to get right, so it blocks — not because the design is wrong, but because the ordering is fixable and the fix is well-scoped (defer the destructive steps until both reads authorize, or restore state on refusal). The deferred spawn-owner kill finding is smaller but cuts the same way: a force operation quietly becoming non-forceful for one child class contradicts the PR's own stated semantics.

To @wenshao — your approval is on record and your A/B is the strongest evidence this PR has; this disagrees with it on one narrow scenario that run didn't exercise (a cron turn registering a shell while a conditional close drains it), and the Stage 2 comment has the full trace if the code reads differently to you than it did to me. To @doudouOUC — this is close; the retention design itself is sound and independently verified, so the ask is the conditional-close ordering plus the tombstone branch, with regression tests that pin both, and the four suggestions are worth sweeping in the same round.

Housekeeping: a CHANGES_REQUESTED review from this bot account already stands on fe9e0b9f3 (from the /review reverse-audit run) and gates the PR; this re-run independently confirms its Critical, so I'm not stacking a duplicate review. The earlier approval from this account on the same commit predates that review and does not reflect this pass's verdict.

中文说明

信心:2/5 —— 设计确实出色,维护者的真实栈 A/B 也已经钉住了 PR 所做的全部行为声称;但重跑确认了一个那些运行未触及的阻塞缺陷——新的 drain 后拒绝路径可能保留一个其排队工作已被 drain 摧毁的 Session——因此我无法为现状背书批准。

退一步看:我在读 diff 之前的独立方案与 PR 实现的形状相同——一个派生的聚合 hold、只在 wire 处过滤、协商类别、fail-closed 保留——我仍然认为这个形状是对的;PR 的版本在我核对过的边界上比我的更好。首轮我漏掉、本轮逐行核实的是:conditional-close 修复的破坏性步骤排在了可以拒绝关闭的读取之前——cancelPendingPrompt() 会先清空 cron/goal/notification 队列并停止 cron scheduler,而被保留的 detached Session 没有任何路径重启它们。PR 自己的注释说出了这个竞态,修复只是在破坏的错误一侧接住了它。这是在本 PR 赖以存在的保留领域中静默且永久地丢失计划内工作,因此构成阻塞——不是设计错了,而是顺序可修且修复范围明确(把破坏性步骤推迟到两次读取都授权之后,或在拒绝时恢复状态)。延迟 spawn-owner kill 的发现小一些,但方向一致:一个强制操作对某一类 child 静默地不再强制,与 PR 自己声明的语义矛盾。

@wenshao —— 你的批准记录在案,你的 A/B 是本 PR 最有力的证据;本结论与之分歧于该运行未演练的一个窄场景(cron turn 在 conditional close drain 期间注册 shell),如果代码在你看来与我的结论不同,Stage 2 评论里有完整的逐行追踪。@doudouOUC —— 这个 PR 离合并很近;保留设计本身是扎实且已被独立验证的,因此请求是修正 conditional-close 顺序与 tombstone 分支,补上钉住两者的回归测试,四个 Suggestion 值得在同一轮一并处理。

事务性说明:本 bot 账号在 fe9e0b9f3 上已有一条 CHANGES_REQUESTED review(来自 /review reverse-audit 运行)正在门禁本 PR;本次重跑独立确认了其 Critical,因此不再叠加重复的 review。该账号在同一提交上更早的批准早于那条 review,不代表本轮结论。

Qwen Code · qwen3.8-max

Reviewed at fe9e0b9f305fbc8c474d8fb9f7433bc9a71fd083 · 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.

Not explored to full depth (tool budget reached): "You are review agent reverse-audit — Reverse audit agent…": none — no check was left unfinished.; "This PR extends the Session activeWork retention fact to…": None — no checks were left unfinished.; "You are review agent reverse-audit — Reverse audit agent…": none — all checks above completed within budget.; "This PR extends the Session activeWork retention fact to…": none — all checks I started completed within budget.; "This PR extends the Session activeWork retention fact to…": none — all checks I started were completed within budget., and 1 more.

中文说明

未探索到全部深度(达到工具调用预算):"You are review agent reverse-audit — Reverse audit agent…"none — no check was left unfinished."This PR extends the Session activeWork retention fact to…"None — no checks were left unfinished."You are review agent reverse-audit — Reverse audit agent…"none — all checks above completed within budget."This PR extends the Session activeWork retention fact to…"none — all checks I started completed within budget."This PR extends the Session activeWork retention fact to…"none — all checks I started were completed within budget.,另有 1 条。

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

Comment on lines +4385 to +4390
// Existing out-of-scope work such as a cron turn may have registered a
// background shell while it drained. Re-check after every active turn
// has settled and while the close gate still blocks new ones; only this
// read can authorize the destructive recorder/session cleanup below.
if (opts?.onlyIfUnheld) {
const holds = session.collectActiveWorkHolds();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] The new post-drain rejection can retain a Session after the close probe has already destroyed its queued work — the drain is destructive even when it refuses to close. Before this second read runs, the drain's session.cancelPendingPrompt() (Session.ts:3352-3432) aborts the running turn, clears cronQueue, splices and aborts queued goal turns, clears notificationQueue, suspends the todo stop guard, and unconditionally calls #stopCronSchedulerInRuntime().

Failure scenario: a detached Session runs an out-of-scope cron turn (invisible to activeWork by design) → conditional close passes the early read → the drain runs cancelPendingPrompt() → the aborting turn registers a background shell just before the abort lands (the exact race this second read exists for) → this read finds the shell hold and returns closed: false, so the Session is retained — but its cron scheduler is stopped (the only restart sites are Session creation and prompt()'s finally; cron turns run via #executeCronPromptInner, so a detached retained session never restarts it), its queued cron/@wakeup/goal items are permanently gone, and nothing logs any of it. Pre-PR this destruction was always followed by teardown, so it was harmless; the new reject-after-drain outcome is what makes it loss. The drain-timeout variant reaches the same destroyed-but-retained state.

Suggested fix: on the onlyIfUnheld path, defer the destructive steps until after authorization — wait for natural settlement (waitForActiveTurnsToSettle() bounded by drainTimeoutMs) before this read, and run the generation-controller aborts and cancelPendingPrompt() only once both reads came back empty (right before recorder?.finalize()); alternatively, restore the cron scheduler and re-arm the cleared queue state on the rejection path.

中文说明

新的 drain 后复查拒绝路径可能在关闭探测已经摧毁 Session 排队工作之后才保留该 Session —— 即使最终拒绝关闭,drain 本身也是破坏性的。在这次第二次读取运行之前,drain 中的 session.cancelPendingPrompt()(Session.ts:3352-3432)会中止正在运行的 turn、清空 cronQueue、splice 并中止排队的 goal turns、清空 notificationQueue、挂起 todo stop guard,并无条件调用 #stopCronSchedulerInRuntime()

失败场景:一个 detached Session 正在运行范围外的 cron turn(按设计对 activeWork 不可见)→ conditional close 通过了提前读取 → drain 运行 cancelPendingPrompt() → 被中止的 turn 在中止落地前一刻登记了一个后台 shell(正是这第二次读取要防的竞态)→ 本次读取发现 shell hold 并返回 closed: false,Session 被保留 —— 但其 cron scheduler 已被停止(重启点只有 Session 创建和 prompt()finally;cron turn 通过 #executeCronPromptInner 执行,因此 detached 且被保留的 session 永远不会重启它),排队的 cron/@wakeup/goal 项永久丢失,且没有任何日志。此 PR 之前,这种破坏之后总是紧跟 teardown,因此无害;新的“先破坏、后拒绝”结果才使其成为损失。drain 超时变体同样到达“被破坏但被保留”的状态。

建议修复:在 onlyIfUnheld 路径上,把破坏性步骤推迟到授权之后 —— 在本次读取之前先等待自然结束(以 drainTimeoutMs 为界的 waitForActiveTurnsToSettle()),只有当两次读取都为空时才运行 generation-controller abort 和 cancelPendingPrompt()(紧接在 recorder?.finalize() 之前);或者在拒绝路径上恢复 cron scheduler 并重建被清空的队列状态。

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

Comment on lines +2241 to +2243
capability &&
!owner.isQuarantined &&
!owner.restoreSettlementOverdue &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The new incomplete-reporting guard runs ahead of the shared automatic-cleanup funnel's deferred spawn-owner kill branch in maybeCloseIdleSession (bridge.ts:2284), so an explicitly requested kill is silently never executed for sessions owned by a negotiated-but-incomplete child — even when no shell is running.

Failure scenario: new daemon + old v1 child (responds categories: ['agent','notification']). A spawn owner's transport drops while another client is attached → killSession(..., {requireZeroAttaches: true}) sets the spawnOwnerWantedKill tombstone → when the last attached client later detaches, entryIsAutoCloseCandidate returns false before reaching the tombstone branch; maybeCloseIdleSession is the tombstone's only resolution site and the reaper funnels through the same guard → nothing ever resolves it (the child is healthy, so it is never condemned), and the session and channel are retained indefinitely despite an explicit kill request. The kill path consults no child predicate — kill is a force operation everywhere else — so the guard protects nothing here while contradicting the design doc's "kill … keep their force semantics".

Suggested fix: handle entry.spawnOwnerWantedKill && entry.attachCount === 0 before this new guard (killSession is force and never trusts the child's answer), or explicitly document that deferred owner-requested kills are also suspended for incomplete children.

中文说明

新的“上报不完整”保护位于共享自动清理漏斗中延迟的 spawn-owner kill 分支(bridge.ts:2284)之前,因此对于协商过但类别不完整的 child 拥有的 session,显式请求的 kill 会被静默地永远不执行 —— 即使没有任何 shell 在运行。

失败场景:新 daemon + 旧 v1 child(响应 categories: ['agent','notification'])。spawn owner 的 transport 在另一个 client 仍 attached 时断开 → killSession(..., {requireZeroAttaches: true}) 设置 spawnOwnerWantedKill tombstone → 当最后一个 attached client 随后 detach 时,entryIsAutoCloseCandidate 会在到达 tombstone 分支之前返回 false;maybeCloseIdleSession 是该 tombstone 唯一的解决点,而 reaper 也经过同一个保护 → 该 tombstone 永远不会被解决(child 健康,因此不会被 condemn),session 和 channel 尽管有显式 kill 请求仍被无限期保留。kill 路径不询问 child 的任何谓词 —— kill 在其他所有地方都是强制操作 —— 所以该保护在这里没有保护任何东西,却与设计文档中“kill……保持强制语义”相矛盾。

建议修复:在这个新保护之前处理 entry.spawnOwnerWantedKill && entry.attachCount === 0killSession 是强制操作,从不信任 child 的回答),或者明确记录:对类别不完整的 child,延迟的 owner 请求 kill 同样被暂停。

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

Comment on lines +2244 to +2246
ACTIVE_WORK_HOLD_CATEGORIES.some(
(category) => !capability.categories.includes(category),
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The "negotiated capability covers every required category" predicate is now hand-inlined twice in this file: the pre-existing copy inside activeWorkCoverage (health grading, bridge.ts:6749-6753) and the identical copy this diff adds to the cleanup-candidate guard.

Concrete cost: the two call sites answer the same question for two consumers (/health?deep=1 grading and the automatic reaper), and the design docs anticipate category-set growth — when the predicate semantics change (a newly added category should degrade the health grade but is not yet required for cleanup, or a required/advisory distinction is introduced), the edit must land in both expressions; missing one makes the reaper and the health surface disagree about the same session (health reports partial while cleanup still authorizes teardown, or vice versa), silently, because both sites compile.

Suggested fix: extract one predicate — e.g. reportsAllActiveWorkCategories(capability) in bridgeTypes.ts, which both call sites already import — and call it from both the cleanup guard and activeWorkCoverage.

中文说明

“协商能力覆盖全部必需类别”这一谓词现在在本文件中被手工内联了两次:activeWorkCoverage(健康分级,bridge.ts:6749-6753)中的既有副本,以及本 diff 为清理候选保护新增的相同副本。

具体代价:两个调用点为两个消费者(/health?deep=1 分级与自动 reaper)回答同一个问题,而设计文档已预期类别集合会增长 —— 当谓词语义变化时(新增类别应降低健康分级但尚不要求用于清理,或引入必需/建议的区分),修改必须同时落在两个表达式上;漏掉一个会让 reaper 与健康面板对同一个 session 给出不一致的结论(健康报 partial 而清理仍授权 teardown,或反之),而且因为两处都能编译,这种分歧是静默的。

建议修复:抽取一个谓词 —— 例如放在两个调用点都已 import 的 bridgeTypes.ts 中的 reportsAllActiveWorkCategories(capability) —— 并在清理保护和 activeWorkCoverage 中调用它。

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

Comment on lines +4689 to +4691
? ACTIVE_WORK_HOLD_CATEGORIES.filter((category) =>
requestedActiveWorkCategories.includes(category),
)

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] This child-side category-intersection parse duplicates the daemon-side parse at bridge.ts:3581-3585 — the same ACTIVE_WORK_HOLD_CATEGORIES.filter expression across the two endpoints of this one handshake, with only the non-array fallback differing (legacy baseline vs [], deliberately, per wire direction). The interval half of this very handshake already sets the precedent the categories half skips: clampActiveWorkIntervalMs is a shared helper in bridgeTypes.ts used by both sides.

Concrete cost: this expression defines what counts as a negotiated category on the active-work v1 wire; if the intersection semantics must change (stricter element validation, or echoing unknown categories forward for a future probe), the edit has to be made independently in two packages that are the two endpoints of the same handshake — missing one side makes a new child and new daemon compute different negotiated sets for the same exchange, which is exactly the version-skew drift category negotiation exists to prevent.

Suggested fix: add a shared helper in packages/acp-bridge/src/bridgeTypes.ts (both files already import from it), e.g. intersectActiveWorkCategories(raw: unknown): ActiveWorkHoldCategory[] | undefined returning undefined for a non-array, and let each side apply its own direction-specific fallback ([] daemon-side, ACTIVE_WORK_LEGACY_HOLD_CATEGORIES child-side).

中文说明

child 侧新增的类别交集解析与 daemon 侧 bridge.ts:3581-3585 处的解析重复 —— 同一个握手的两个端点使用相同的 ACTIVE_WORK_HOLD_CATEGORIES.filter 表达式,只有非数组回退不同(按 wire 方向有意区分:legacy 基线 vs [])。同一握手中 interval 的一半已经树立了被 categories 一半跳过的先例:clampActiveWorkIntervalMsbridgeTypes.ts 中双方共用的 helper。

具体代价:该表达式定义了 active-work v1 wire 上什么算作已协商类别;如果交集语义必须改变(更严格的元素校验,或为未来探测回显未知类别),就必须在同一握手两个端点所在的两个包中独立修改 —— 漏掉一端会让新 child 与新 daemon 对同一次交互算出不同的协商集合,这正是类别协商机制要防止的版本漂移。

建议修复:在 packages/acp-bridge/src/bridgeTypes.ts(两个文件都已 import 它)中新增共享 helper,例如 intersectActiveWorkCategories(raw: unknown): ActiveWorkHoldCategory[] | undefined(非数组返回 undefined),由两端各自应用方向特定的回退(daemon 侧 [],child 侧 ACTIVE_WORK_LEGACY_HOLD_CATEGORIES)。

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

Comment on lines +2239 to +2242
const capability = owner?.activeWork;
if (
capability &&
!owner.isQuarantined &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The !owner.isQuarantined escape hatch in this new guard has no test. Probed empirically: deleting the clause survives the full bridge suite (577/577 tests still pass). The clause is not dead — channel.isQuarantined is set at bridge.ts:5948 after a failed late-restore cleanup, and quarantine can coincide with an incomplete capability (new daemon + old child).

Failure scenario: a channel quarantined after a failed late-restore cleanup whose child negotiated only agent/notification, holding an idle detached session → ordinary auto cleanup is expected to stay enabled for quarantined channels (that is what the carve-out encodes), but with the clause deleted such sessions become permanently unreapable by ordinary cleanup, and no existing test would fail.

Suggested fix: add a bridge test combining the quarantine setup from 'quarantines only fresh work when late restore cleanup fails' with a legacy-categories activeWorkInitializeResponse, then detach and assert a conditional close is attempted / the session is reaped.

中文说明

这个新保护中的 !owner.isQuarantined 豁免分支没有测试。经实证探测:删除该子句后整个 bridge 测试套件仍然通过(577/577)。该子句并非死代码 —— channel.isQuarantined 会在 late-restore 清理失败后于 bridge.ts:5948 被设置,而 quarantine 状态可能与不完整能力并存(新 daemon + 旧 child)。

失败场景:一个在 late-restore 清理失败后被 quarantine 的 channel,其 child 只协商了 agent/notification,持有一个空闲 detached session → 普通自动清理对 quarantined channel 应保持启用(这正是该豁免分支编码的语义),但若该子句被删除,这类 session 将永远无法被普通清理回收,而现有任何测试都不会失败。

建议修复:新增一个 bridge 测试,把 'quarantines only fresh work when late restore cleanup fails' 中的 quarantine 布置与 legacy 类别的 activeWorkInitializeResponse 组合,然后 detach 并断言会尝试 conditional close / session 被回收。

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

Comment on lines +2733 to +2735
sessionId: 'legacy-active-work-session',
closed: false,
holds: [{ category: 'shell', id: 'background-shells' }],

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] No assertion in this test discriminates the early under-gate hold rejection from the post-drain rejection. Probed empirically: deleting the first collectActiveWorkHolds() read (acpAgent.ts:4354-4359) survives the entire acpAgent suite (395/395); adding the assertions below fails the mutant and passes on the unmodified code.

Failure scenario: the drain-phase mocks (waitForActiveTurnsToSettle, cancelPendingPrompt) are no-ops returning success, so with the early read deleted the second read rejects with the identical {closed: false, holds} payload and this test still passes. Under that mutant in production, a conditional close on a session with already-known holds first runs the destructive drain phase — aborting generation controllers, calling session.cancelPendingPrompt(), and waiting up to SESSION_DRAIN_TIMEOUT_MS (30s) — instead of rejecting immediately, past the daemon's ACTIVE_WORK_CLOSE_TIMEOUT_MS (10s) budget where an instant rejection would have fit.

Suggested fix:

expect(lastSessionMock?.waitForActiveTurnsToSettle).not.toHaveBeenCalled();
expect(lastSessionMock?.cancelPendingPrompt).not.toHaveBeenCalled();
中文说明

本测试中没有任何断言能区分“close gate 下的提前拒绝”与“drain 之后的拒绝”。经实证探测:删除第一次 collectActiveWorkHolds() 读取(acpAgent.ts:4354-4359)后整个 acpAgent 套件仍然通过(395/395);加入下方断言后该变异体会失败,而在未修改代码上通过。

失败场景:drain 阶段的 mock(waitForActiveTurnsToSettlecancelPendingPrompt)是返回成功的空操作,因此删除提前读取后,第二次读取会以相同的 {closed: false, holds} 载荷拒绝,本测试依然通过。在生产环境下若存在该变异体:对已有已知 hold 的 session 执行 conditional close 时,会先运行破坏性的 drain 阶段 —— 中止 generation controllers、调用 session.cancelPendingPrompt()、最多等待 SESSION_DRAIN_TIMEOUT_MS(30 秒)—— 而不是立即拒绝;这超出了 daemon 的 ACTIVE_WORK_CLOSE_TIMEOUT_MS(10 秒)预算,而立即拒绝本可以在预算内完成。

建议修复:

expect(lastSessionMock?.waitForActiveTurnsToSettle).not.toHaveBeenCalled();
expect(lastSessionMock?.cancelPendingPrompt).not.toHaveBeenCalled();

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

@wenshao

wenshao commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Independent verification report (local real-stack A/B)

Verified fe9e0b9f3 against its merge-base abfd44369 with two clean detached worktrees, fresh npm install, full npm run build + npm run bundle each, and a real stack: qwen serve daemon → spawned --acp child → scripted OpenAI-compatible mock model that answers the first prompt with run_shell_command(sleep N, is_background: true) and then settles the turn. Judges were GET /health?deep=1 and GET /session/:id/tasks sampled every 1s, the mock model's request ledger (continuation delivery), the daemon log (reaper decisions), and the real sleep PID. Cross-version pairing used the QWEN_CLI_ENTRY child override.

Result: every claim in the PR description reproduced. No regressions found. LGTM from my side.

1. Deep health during the shell lifecycle (before/after)

Base: with the prompt settled and the shell task running (real PID alive), deep health reads activePrompts: 0, activeWork: false, activeWorkReporting: "full" for the entire 25s — a restart controller reading the documented three-term rule would call this daemon idle. Head: activeWork: true the whole time the shell runs, stays true with no gap through completed→queued-notification→continuation (~4s), and flips false only after the continuation settles.

lifecycle A/B

2. The harm, end to end (idle reaper, TTL 2s, client detaches with shell running)

Base: session reaped 2s after detach, the running sleep 120 was killed ~118s early, and the shell's terminal notification never reached the parent continuation (0 continuation requests hit the model). Head, same scenario: conditional close refused while shell/notification/continuation were active; the daemon log shows the reap landing only at idle for 24s, threshold 2s, i.e. right after the continuation settled, and exactly 1 continuation request reached the model.

reaper A/B

3. Cross-version compatibility (real old/new binaries via QWEN_CLI_ENTRY)

  • New daemon + old v1 child (idle session, TTL 2s): health grades activeWorkReporting: "partial" and the session is never auto-reaped over 30s — negotiated-but-incomplete coverage disables ordinary automatic cleanup, as designed.
  • Old daemon + new child (running shell, TTL 2s): the wire carries only legacy categories so the old daemon reads activeWork: false throughout, but its per-second reap attempts are refused by the child's unfiltered local close check for the full 22s until the shell finished and the continuation settled; the notification was delivered (1 continuation request).

compat

4. Force semantics + suites

POST /sessions/delete with activeWork: true and a running sleep 300 removed the session immediately and killed the shell — explicit close ignores holds, unchanged. Directed suites on head: backgroundShellRegistry 61 passed, bridge 577 passed, cli (active-work-reporter, acpAgent, Session, Session.review-lease, Session.worktree) 1010 passed. Repo-wide npm run typecheck passes on a clean checkout (exit 0) — the ink-patch blockage mentioned in the PR description appears to be a local environment issue only.

force + tests

Environment: macOS (Darwin 25.6.0), Node 24; daemons on loopback with isolated QWEN_HOME per arm; reaper accelerated with --session-reap-interval-ms 1000 --session-idle-timeout-ms 2000 where noted.

中文版本(Chinese version)

独立验证报告(本地真实栈 A/B)

以 merge-base abfd44369 为对照,在两个干净的 detached worktree 中分别全量构建并打包 fe9e0b9f3,用真实栈验证:qwen serve daemon → 拉起的 --acp 子进程 → 脚本化 OpenAI 兼容 mock 模型(首个 prompt 回复 run_shell_command(sleep N, is_background: true) 后立即结束回合)。判据为每秒采样的 GET /health?deep=1GET /session/:id/tasks、mock 模型请求台账(continuation 是否送达)、daemon 日志(reaper 决策)以及真实 sleep 进程 PID。跨版本配对使用 QWEN_CLI_ENTRY 子进程覆盖。

结论:PR 描述中的每一项声称均已复现,未发现回归。

1. shell 生命周期内的 deep health(前后对比)

Base:prompt 已结束、shell 任务运行中(真实 PID 存活)时,deep health 全程 25 秒返回 activePrompts: 0, activeWork: false, activeWorkReporting: "full"——按文档的三条件规则,重启控制器会把 daemon 判为空闲。Head:shell 运行期间 activeWork: true 持续为真,经 完成→通知排队→continuation(约 4 秒)无空档保持为真,continuation 结束后才翻为 false。

2. 端到端危害复现(idle reaper,TTL 2 秒,客户端在 shell 运行中断开)

Base:断开 2 秒后会话即被收割,运行中的 sleep 120 提前约 118 秒被杀,shell 终态通知从未到达父 continuation(模型收到 0 个 continuation 请求)。Head 同场景:shell/通知/continuation 活跃期间条件关闭被拒;daemon 日志显示直到 idle for 24s, threshold 2s(即 continuation 刚结束)才收割成功,模型恰好收到 1 个 continuation 请求。

3. 跨版本兼容(QWEN_CLI_ENTRY 配对真实新旧二进制)

  • 新 daemon + 旧 v1 child(空闲会话,TTL 2 秒):health 评级 activeWorkReporting: "partial",30 秒内会话从未被自动收割——协商过但类别不全的 child 禁用普通自动清理,符合设计。
  • 旧 daemon + 新 child(shell 运行中,TTL 2 秒):wire 上只有旧类别,旧 daemon 全程读到 activeWork: false,但其每秒一次的收割尝试被 child 本地未过滤的关闭检查连续拒绝 22 秒,直到 shell 完成、continuation 结束;通知成功送达(1 个 continuation 请求)。

4. 强制语义 + 测试

activeWork: truesleep 300 运行中执行 POST /sessions/delete:会话立即删除、shell 被杀——显式关闭无视 hold,语义不变。head 上定向套件:backgroundShellRegistry 61 通过、bridge 577 通过、cli 五个套件 1010 通过。全仓 npm run typecheck 在干净 checkout 上通过(exit 0)——PR 描述中提到的 ink patch 阻塞应为作者本地环境问题。

环境:macOS(Darwin 25.6.0)、Node 24;daemon 绑定 loopback,每个实验臂独立 QWEN_HOME;标注场景下用 --session-reap-interval-ms 1000 --session-idle-timeout-ms 2000 加速 reaper。

@wenshao

wenshao commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 13, 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: 38 passed · 0 failed · 38 total

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

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

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

Verification report

PR 9042 Deep Verification — feat(daemon): Track background shells in activeWork

Verdict: merge-ready — 38/38 scripted assertions passed (0 unexpected failures).
Verified head: fe9e0b9f305fbc8c474d8fb9f7433bc9a71fd083 (merge-ref checkout, base tip 3378212b5f).

中文摘要

结论:merge-ready。38/38 脚本化断言通过,无意外失败。

A/B 结论(见下表及 01-…02-… 截图):

  • 核心声明成立:Session 管理的后台 shell 运行时(或其终态通知排队/驱动 continuation 时),collectActiveWorkHolds() 产出 {category:'shell', id:'background-shells'} 聚合 hold,经快照上链后 daemon 深度健康面 activeWork: true、普通自动清理保留该 Session。base 侧同一物理状态无任何 shell hold(测试在 base 上以行为断言失败:expected [] to deeply equal ['background-shells']),且同一个旧版 child(声明 ['agent','notification']、报告为空新快照)在 base 被回收、在 head 被保留——兼容性翻转在真实 bridge + 内存 NDJSON 通道上实测成立。
  • 定位:base daemon 本身对 hold 是泛化接收的,缺陷完全在 child 侧(从不上报 shell hold);而且 base 的 parseActiveWorkSnapshot 会把含未知类别的整份快照整体拒收(fail-closed 转 unknown),所以即便旧 child 上报 shell hold 也无法被 base 利用。
  • 协商兼容四象限全部实测:新 daemon × 旧 child → partial + 禁止普通清理(显式 close 仍强制);新 child × 旧 daemon → 线上仅旧类别、本地 conditional close 仍拒绝;未协商 child 两臂行为不变(A/A 对照)。
  • drain 后复检关闭竞态窗口:变异矩阵 M3 证实去掉该 hunk 后新增测试按预期断言变红。

Findings:无阻塞项。两条非阻塞观察(见正文 Findings):① base 侧 confirmChildUnheld 采纳被拒 holds 时按旧类别过滤,混合配对(旧 daemon × 新 child)下旧 daemon 会周期性重复询问(child 持续拒绝,保留语义不受影响;旧 daemon 已发布,本 PR 无法也无需修复);② 病态 categories: [] 应答会使 Session 被 fail-closed 保留——属设计意图内的保守方向,无提权路径,已验证。

未覆盖:真实模型驱动的端到端 deep-health(沙箱无模型凭据,链路逐段实测替代);Windows/macOS 差异;Monitors/workflows/cron/PID 探测(PR 声明范围外);仓库全量测试。

Central claim and A/B evidence

Central claim: while a Session-managed background shell is running — or its terminal notification is queued or driving the parent continuation — deep health reports activeWork: true and ordinary automatic cleanup retains the Session; pre-PR, the same physical state reported activeWork: false and the Session was reapable.

The chain was proven segment by segment, A/B on both sides:

Child side (Session → reporter): the load-bearing flip

The PR's own new tests were run against both trees (identical test files; only production source differs — test copies in the base tree; vitest aliases resolve every @qwen-code/* import to worktree-relative sources, and tmp/base-tree/node_modules/@qwen-code/* symlinks were realpath-verified to point into the base tree):

Arm Suite result Oracle
head Session 604/604, reporter 10/10, acpAgent 407/407 all green
base 7 failed / 607 passed exactly the 7 new shell-behavior tests, each failing the intended behavioral assertion (expected [] to deeply equal [ 'background-shells' ], shell hold leaked onto the wire for the no-categories daemon, missing post-drain re-check). All 607 pre-existing assertions stay green — no harness damage
base acpAgent 2 failed / 405 passed wire-filter + drain re-check
base bridge 3 failed / 584 passed categories advertised in initialize request; incomplete-child retention; shell-hold grading
base core registry 1 failed / 60 passed clearStatusChangeCallback missing API (TypeError)

Witness: 02-ab-session-child-side.png, 03-mutation-matrix.png, and 04-m1-revert-kills-shell-tests.png (the emission hunk reverted in a scratch worktree kills 5/6 shell tests on the intended assertions; the 6th is pinned by the adjacent wiring hunk).

Daemon side (bridge): real bridge over in-memory NDJSON channels

bridge-ab.mjs (in this artifact dir) drives the compiled bridge of each tree through the package's own internal/testUtils fake-channel seam (the bridge under test is never stubbed; the fake child encodes the wire behavior of a real pre-PR child):

Cell head base
C1 legacy child (advertises exactly ['agent','notification'], fresh empty snapshots), detach + 25 reaper ticks @​10 ms retained: 0 conditional closes, sessionCount 1, grade partial; explicit closeSession then force-closes (forced=1) reaped: conditional close issued, sessionCount 0, grade full
C2 full-coverage child reports a shell hold activeWork: true, grade full, oldestCoveredReportAt stamped (hold accepted) activeWork: true — but only via the fail-closed unknown path: base parseActiveWorkSnapshot rejects the entire snapshot on the unknown category (covered: 0, grade partial, nothing applied), then the empty release snapshot applies and activeWork: false
C3 never-negotiated child (A/A control) reaped (forced local close) reaped — identical; the new guard does not touch legacy-never-negotiated cleanup

Head arm 10/10, base arm 9/9 (arm-aware expectations encoded in the harness). Witness: 01-ab-bridge-daemon-side.png.

Two facts from C2 sharpen the defect localization: (a) the base daemon was already generic enough to hold arbitrary categories — the pre-PR defect is entirely child-side (no shell hold was ever emitted); (b) base would not even have tolerated a hypothetical shell report — the whole-snapshot rejection means the session would read "unknown", go to confirmChildUnheld, and the pre-PR child (which never checks shells) would answer closed: true — i.e. reaped while the shell runs, end to end, on every layer.

Reviewer Test Plan, per step

Plan step Result
Long-running shell + settled prompt → deep health activePrompts: 0, activeWork: true, activeWorkReporting: "full" Proven at component level: emission (M1-oracle), wire inclusion under full negotiation (M4-oracle), bridge activeWork/grade full (C2). The /health?deep=1 handler reads exactly bridge.activeWork + activeWorkCoverage (reviewed serve/routes/health.ts); live HTTP with a model-started shell not run (no credentials) — see Not covered
Detach client → ordinary cleanup preserves the Session C1 head: detach + 25 reaper ticks, 0 conditional closes
Shell finishes with blocked continuation → hold through running→queued→continuation, false only after settlement Session tests pin each transition (handoff / failure / cancelled variants); green head, red base, red under M1
Negotiated child omitting shell → partial reporting, excluded from ordinary cleanup C1 head + base-arm red of the retention test + M2
Completely unsupported child keeps legacy cleanup C3 both arms (A/A)
Explicit close / kill remain forceful C1.5 head; PR bridge force-close/kill test passes on both arms (A/A for the invariant)

Corrections

None needed for earlier review rounds. One correction to the reading of my own first base-arm run, applied before the final numbers: the base C2 activeWork: true initially looked like "base accepts shell holds"; the coverage counters (covered: 0, oldestCoveredReportAt: null) proved it was the fail-closed unknown state after whole-snapshot rejection. The final harness encodes the corrected mechanics.

Findings

No blocking findings.

  1. (Note — mixed-version pairing, base-side, not fixable by this PR) Base confirmChildUnheld adopts the hold set a refusing child hands back, filtered through base's category list (packages/acp-bridge/src/bridge.ts, adoption path). Under old-daemon × new-child, a refused shell hold is adopted as an empty set with a fresh timestamp, so the old daemon's cache records "no work" and it re-asks the conditional close on subsequent cleanup evaluations (each snapshot / reaper tick) until the shell ends. Retention itself is unaffected — the new child keeps refusing locally (proven by the acpAgent legacy test) — but operators on mixed versions will see repeated bounded round trips. This is pre-existing released-daemon behavior the PR cannot change; documenting it so the pairing's observable is not mistaken for a regression.
  2. (Verified non-issue) A child answering the handshake with categories: [] is retained from ordinary cleanup by the new guard (fail-closed). This cannot arise from the daemon itself (it always advertises the full list) and the consequence is retention, never teardown or privilege escalation — the conservative direction of the design. Probed by reading the guard + the intersection code; no harness cell spent.

Not covered

  • Live end-to-end deep health (/health?deep=1 over HTTP with a model-prompt-started background shell): requires model API credentials the sandbox does not have. The claim is reproduced in shape, not in trigger: every segment of the chain was driven on real code (real registry transitions, real reporter wire snapshots, real bridge state, reviewed health route), but the model→shell-start trigger itself was not run.
  • Per-commit attribution: single-commit PR (fe9e0b9f30), verified as the aggregate HEAD^1..HEAD diff; consistent with the metadata snapshot's commits array.
  • Windows/macOS differences: mechanism is platform-independent TypeScript state (no PID probing, no process signals on the changed paths); the PR marks them untested too.
  • Monitors, workflows, cron, follow-up work, PID probing, shell stall detection: declared out of scope by the PR; not exercised.
  • Repo-wide test suite / integration lanes: targeted gates only (affected workspaces).
  • Base-arm adaptation: bridge.test.ts and acpAgent.test.ts import ACTIVE_WORK_LEGACY_HOLD_CATEGORIES, which the PR introduces; the base copies inline const ACTIVE_WORK_LEGACY_HOLD_CATEGORIES = ['agent', 'notification'] as const (byte-equal to the head value) so the files collect against base source. All base reds quoted are behavioral (expected-vs-actual assertions), not import errors.
  • Mutation M1 granularity: reverting only the emission hunk leaves the 6th shell test ("tracks shell status changes and retracts only its callback") green — it is pinned by the adjacent callback-wiring hunk (install + identity-safe retraction), which the base-arm TypeError red and the wiring assertions cover. Both hunks are thus load-bearing; neither test is vacuous.

Methodology

Environment: the CI merge-ref checkout (HEAD = merge commit, depth 2) on node:22-bookworm; npm ci + npm run build pre-run at HEAD. A/B infrastructure: git worktree add tmp/base-tree HEAD^1 with tmp/base-tree/node_modules/@qwen-code/* symlinked to the base packages (realpath-verified) and nested third-party packages/*/node_modules symlinked from the head install (zero @qwen-code entries inside; package.json/lockfile untouched by the PR, so reuse is a clean control). Base core + acp-bridge dists rebuilt in the base tree (@lydell tsconfig paths mapping satisfied by linking node_modules/@lydell). Head mutations ran in a separate tmp/head-mut worktree, never in the PR tree; every mutation restored via git checkout and verified. Harnesses: bridge-ab.mjs drives the compiled bridge through @qwen-code/acp-bridge/internal/testUtils (in-memory NDJSON channel pair, real AgentSideConnection), asserting both sides of the wire (snapshot seq application, conditional-close call counts, coverage counters). Vitest A/B ran the PR's own test files from source on both trees. Gates cited: head core registry 61/61; head cli acp-integration 1021/1021; head review-lease+worktree 12/12; head bridge 587/587; ESLint on the six changed production files clean after a planted-violation liveness proof. Raw logs (head-*.log, base-*.log, bridge-ab-*.log) and harness scripts live in this directory; image evidence under evidence/.

Evidence images

01-ab-bridge-daemon-side

02-ab-session-child-side

03-mutation-matrix

04-m1-revert-kills-shell-tests

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

The bot already has a review of its own on fe9e0b9f305fbc8c474d8fb9f7433bc9a71fd083, which still stands.

机器人在 fe9e0b9f305fbc8c474d8fb9f7433bc9a71fd083 上已有自己的评审,且仍然有效。

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

上方各阶段评论已更新为最新结果。查看工作流运行

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

Approved as requested.

@doudouOUC
doudouOUC dismissed qwen-code-ci-bot’s stale review August 14, 2026 03:25

Already have 2 approves,3ks.

@doudouOUC
doudouOUC added this pull request to the merge queue Aug 14, 2026
Merged via the queue into QwenLM:main with commit 53a7f2f Aug 14, 2026
168 checks passed
@doudouOUC
doudouOUC deleted the feat/active-work-background-shell branch August 14, 2026 03:38
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.12.

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