Skip to content

feat(serve): Expose active work state - #8588

Merged
doudouOUC merged 9 commits into
QwenLM:mainfrom
doudouOUC:agent/serve-active-work-state
Aug 8, 2026
Merged

feat(serve): Expose active work state#8588
doudouOUC merged 9 commits into
QwenLM:mainfrom
doudouOUC:agent/serve-active-work-state

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds three additive fields to GET /health?deep=1activeWork, activeWorkReporting, and activeWorkStaleMs — and the reporting machinery behind them.

activeWork is true while any managed workspace has an accepted-but-unsettled prompt, a running background Agent, or an Agent terminal notification that is queued, awaiting acceptance, or being processed by its parent continuation. It does not cover background shells, Monitors, workflows, or cron; that exclusion is deliberate and documented, because a controller that reads activeWork: false as "nothing at all is running" will be wrong about those.

activeWorkReporting (full / partial / none) says how much of that boolean is actually vouched for, and activeWorkStaleMs is the age of the oldest snapshot it rests on (0 when nothing is covered). Without the grade, activeWork: false cannot be told apart from "no child told me anything", which is the one case where acting on it is unsafe.

Reporting. The daemon and ACP child negotiate a private versioned capability through initialization _meta; the child answers with the cadence it will use and the categories it covers, and each side clamps the other's value into an agreed range. A supported child then publishes channel-wide full snapshots of named holds:

{ "v": 1, "seq": 12, "sessions": [ { "sessionId": "", "holds": [ { "category": "agent", "id": "a1b2" } ] } ] }

Holds are derived on every report from the owners of the work — the background-task registry's unfinalized set, the notification queue, the in-flight acceptance and continuation state. There is no acquire/release ledger, because a ledger can miss a release and a leaked hold would pin its Session forever while every snapshot faithfully republished the leak. Full snapshots make a dropped report self-correcting in both directions, and because a report is complete, a Session absent from a fresh snapshot holds nothing on the child side. Absence and reported-with-no-holds are therefore the same fact and take the same path — one that ends in asking the child, never in assuming.

Prompts are deliberately absent from the child's report: the daemon accepts, queues, dispatches, and settles them, so its own count is authoritative and strictly wider (it covers prompts still waiting in the FIFO, which the child cannot see). A snapshot is flushed ahead of the prompt response on the same stream, so a hold the prompt left behind is on the wire before the daemon drops that count.

Cleanup. Automatic cleanup no longer destroys a Session on the strength of a cached snapshot. It asks the child to close only if unheld, and the child answers under its own close gate — with the gate held no prompt is admitted and no automatic turn starts, so a hold cannot appear between the check and the teardown. A refusal hands back the current holds and the daemon adopts them. An unanswered request is neither retried nor assumed: the Session stays, and the next snapshot settles it. The child's gate makes the check atomic on the child side only; the daemon marks the Session in-flight across the whole confirm-then-teardown span, and attach, prompt, and rewind refuse it there exactly as they refuse one already closing. Detach, attach rollback, prompt settle, notification settle, a child reporting itself idle, and the idle reaper's TTL all funnel through one decision point — the reaper included, so a TTL that says the client stopped caring no longer destroys work the child is still running. Explicit close, kill, shutdown, and channel exit keep their force semantics.

Per Session the daemon tracks three states: unsupported (channel never negotiated — contributes nothing, pre-existing cleanup behavior unchanged), unknown (negotiated, not heard from recently enough — reads as retained, and prompts the daemon to ask), and known. Never-reported and gone-quiet are the same state deliberately: a snapshot older than three report intervals is not a report that the Session is idle, so it stops counting as evidence. Reclaiming a channel that has genuinely stopped answering belongs to transport liveness, not here.

Why it's needed

activePrompts reaches zero when a main prompt finishes, even if background Agents started by that prompt are still running. A restart controller that reads zero active prompts as idle can therefore restart the daemon before those Agents finish and before their terminal notifications reach the parent session. activeWork supplies the missing fact without embedding restart policy in the daemon.

Controllers should use:

const busy =
  health.activePrompts > 0 ||
  health.activeWork ||
  health.activeWorkReporting !== 'full';

Dropping the third term makes activeWork === false indistinguishable from an unreported channel.

What this deliberately does not do

There is no heartbeat watchdog and no channel kill driven by work state. Inferring "this channel is dead" from "one Session stopped reporting" kills every Session on that process, and a host suspend, a long event-loop stall, or a single dropped notification all look identical to a stalled child. Transport/process liveness (channel ping-pong) and stalled-Agent detection (progress-based watchdog) are separate mechanisms tracked as follow-ups under the umbrella issue.

These fields are an observation cache, not a restart lease. Even a fresh, fully-graded, empty answer describes the moment it was sampled; work can start immediately afterwards. The rule above lowers the risk of a wrong restart substantially but does not eliminate it — strict safety needs a prepare-restart fence that stops new work admission, confirms the drain, and only then shuts down. That is out of scope here and stated as such in the docs.

Reviewer Test Plan

  1. Start a prompt that launches a background Agent and lets the main prompt finish first. Confirm deep health reports activePrompts: 0 with activeWork: true until the Agent terminal notification and its parent continuation settle.
  2. Cancel a running background Agent in a detached session. Confirm the Session survives the cancel()finalizeCancelled() window (up to the 5s grace timer) and is not reaped with the terminal notification still owed. This is the concrete bug the hasUnfinalizedTasks() predicate fixes.
  3. Detach the last client while an Agent is active. Confirm the Session is preserved, and that when it does go idle the daemon issues a conditional close rather than closing on the cached snapshot.
  4. Make the child refuse a conditional close (start work between the snapshot and the request). Confirm the Session stays and the daemon adopts the returned hold set.
  5. Stall the child's close response. Confirm the daemon neither retries in place nor assumes closure, that a prompt or rewind attempted during the stalled round trip is refused rather than accepted and lost, and that the next snapshot settles it by asking the child once more.
  6. Connect a child that does not acknowledge the capability. Confirm cleanup behaves exactly as before, activeWorkReporting is none, and the shallow health response remains exactly { "status": "ok" }.
  7. Confirm a true value from one managed or draining workspace makes daemon-wide activeWork true, and that an exception from a later workspace getter still returns 503 aggregation_failed.

Evidence (Before & After)

N/A — daemon protocol, lifecycle, and health-state change with no TUI presentation change. The Serve A/B job's diff table is the check on the public response shape: it should now show three changed fields rather than one.

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ⚠️ not tested
🐧 Linux ✅ unit suites only

Testing status — please read

Unit suites run locally and green: ACP bridge 489/489, ACP agent 383/383, Session 534/534, core background-tasks 123/123, and the three serve suites 1187/1188. That one failure is a pre-existing cross-file flake in the Live Appshot integration tests — it reproduces on the unmodified tree and fails a different test each run.

End-to-end has not been run. None of the seven items above were executed manually; they are for the reviewer (and for CI's Real daemon E2E). Typecheck is clean for core and acp-bridge; cli has 22 residual errors, all from unbuilt workspace packages in the local sandbox and none in any file this PR touches.

Risk & Scope

  • Main risk: this is a cross-package private protocol addition. The failure mode is now bounded in the safe direction — an unreported or unconfirmed Session is retained, never destroyed — so the realistic cost of a bug is a Session lingering until the idle reaper, not lost work or a killed channel. The previous revision of this PR could recycle a healthy ACP channel after missed heartbeats; that mechanism is gone.
  • Not validated / out of scope: macOS and Windows untested; no end-to-end run. Channel liveness, stalled-Agent detection, runtime-generation draining, and unresponsive-Agent escalation are deferred to follow-up PRs under the umbrella issue.
  • Breaking changes / migration notes: the deep-health JSON response gains three additive fields. AcpSessionBridge gains three required readonly members (activeWork, activeWorkReporting, activeWorkOldestReportAt), which is breaking for any external implementer of that interface; all in-repo implementations are updated. The shallow health response and persisted formats are unchanged. Existing restart controllers may ignore the new fields; activePrompts keeps its exact previous meaning as an independent compatibility signal.

Linked Issues

Refs #8586

中文说明

本 PR 做了什么

GET /health?deep=1 增加三个向后兼容字段 —— activeWorkactiveWorkReportingactiveWorkStaleMs —— 以及支撑它们的上报机制。

只要任一受管 workspace 存在已接受但未 settle 的 Prompt、运行中的后台 Agent,或正在排队/等待接收/由父 continuation 处理的 Agent 终态通知,activeWork 即为 true。它不包含后台 shell、Monitor、workflow 和 cron;这是有意为之并写进了文档,因为把 activeWork: false 理解成"什么都没在跑"对这几类就是错的。

activeWorkReportingfull / partial / none)说明这个布尔量有多少是真正被担保的,activeWorkStaleMs 是它所依赖的最旧快照的年龄(无覆盖时为 0)。没有这个分级,activeWork: false 就无法与"没有任何子进程告诉过我"区分开,而后者恰恰是唯一不能据此行动的情形。

上报机制。 daemon 与 ACP 子进程通过初始化 _meta 协商一个私有、带版本的能力;子进程回复它实际采用的上报周期和覆盖的类别,两侧都会把对方给的值钳制到约定区间内。支持该能力的子进程随后发布 channel 级全量快照(见英文段的 JSON 示例)。

Hold 每次上报时现算,来源是工作的真正持有者:后台任务注册表的 unfinalized 集合、通知队列、在途的 acceptance 与 continuation 状态。没有 acquire/release 账本 —— 账本可能漏掉一次 release,而泄漏的 hold 会永久钉住 Session,并被每一份快照忠实地重复上报。全量快照让丢失的报文在两个方向上都能自愈,且某个 Session 未出现在新快照中,就是子进程已释放它的正面证据。

Prompt 有意不由子进程上报:daemon 自己负责接受、排队、下发和 settle,它的计数既权威又严格更宽(覆盖了子进程看不到的 FIFO 等待)。快照会在 prompt 响应之前 flush 到同一条流上,确保该 prompt 留下的 hold 先于 daemon 清零计数抵达。

清理路径。 自动清理不再凭缓存快照销毁 Session,而是请求子进程"仅在无 hold 时关闭",由子进程在自己的 close gate 下作答 —— gate 持有期间不接受新 Prompt、不启动新的自动 turn,因此 hold 不可能在检查与拆除之间出现。被拒绝时返回当前 hold 集合,daemon 予以采纳。请求无应答时既不重试也不假设:Session 保留,由下一份快照裁决。detach、attach 回滚、prompt settle、通知 settle、子进程自报空闲,现在全部汇入同一个决策点,取代原先四处近似重复的逻辑。显式 close、kill、shutdown 和 channel 退出保持强制语义。

daemon 按 Session 维护三态:unsupported(通道从未协商 —— 不贡献任何值,既有清理行为不变)、unknown(已协商但尚未收到上报 —— 视为保留,并促使 daemon 主动询问)、known

为什么需要它

主 Prompt 结束后 activePrompts 即归零,即使它拉起的后台 Agent 仍在运行。把"零活跃 Prompt"直接当作空闲的重启控制器,就可能在这些 Agent 完成、其终态通知抵达父 session 之前重启 daemon。activeWork 补上这个缺失的事实,同时不把重启策略嵌进 daemon。控制器判据见英文段的代码块;去掉第三项会让 activeWork === false 与"未上报的通道"无法区分。

明确不做的事

没有心跳看门狗,也没有由工作状态驱动的 channel kill。 从"某个 Session 停止上报"推断"整条通道已死"会连带杀死该进程上的所有 Session,而主机休眠、长时间 event loop 阻塞、单次报文丢失,在观测上与子进程卡死完全一样。传输/进程存活(通道 ping-pong)与 Agent 停滞检测(基于进度的 watchdog)是独立机制,作为后续 PR 由 umbrella issue 跟踪。

这些字段是观测缓存,不是重启租约。即使是新鲜、分级完整、且为空的回答,描述的也只是采样那一刻;工作可能紧随其后开始。上面的判据能显著降低误重启风险,但不能消除 —— 严格安全需要一个 prepare-restart 栅栏:先停止新工作准入,确认 drain,然后才停机。这不在本 PR 范围内,文档中已如实写明。

Reviewer 测试计划

见英文段的 7 条。其中第 2 条(在 detached session 中 cancel 一个后台 Agent,确认它能挺过 cancel()finalizeCancelled() 窗口)针对的是本次修复的一个具体缺陷。

测试状态 —— 请务必阅读

本地单测全绿:ACP bridge 489/489、ACP agent 383/383、Session 534/534、core background-tasks 123/123、serve 三套 1187/1188。那 1 条失败是 Live Appshot 集成测试里既有的跨文件 flake —— 在未修改的代码树上同样复现,且每次失败的是不同的 test。

端到端没有跑过。 上述 7 条没有任何一条被手动执行,它们留给评审者(以及 CI 的 Real daemon E2E)。typecheck 方面 coreacp-bridge 干净;cli 残留 22 条,全部源自本地沙箱中未构建的 workspace 包,无一落在本 PR 改动的文件上。

风险与范围

  • 主要风险: 这是一个跨 package 的私有协议增量。故障方向现在被限制在安全侧 —— 未上报或未确认的 Session 一律保留,绝不销毁 —— 所以出 bug 的现实代价是 Session 滞留到 idle reaper 回收,而不是丢失工作或杀掉通道。本 PR 的上一版会在心跳缺失后回收健康的 ACP 通道,该机制已被移除。
  • 未验证 / 范围外: 未测试 macOS 与 Windows;未跑端到端。通道存活、Agent 停滞检测、runtime 代际 draining、不响应 Agent 的升级处置,均留给 umbrella issue 跟踪的后续 PR。
  • 破坏性变更 / 迁移说明: 深度健康 JSON 响应新增三个字段。AcpSessionBridge 新增三个必需只读成员(activeWorkactiveWorkReportingactiveWorkOldestReportAt),对该接口的外部实现者构成破坏性变更;仓库内所有实现均已更新。浅层健康响应与持久化格式不变。现有重启控制器可以忽略新字段;activePrompts 保持原有语义,作为独立兼容信号。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

E2E test report

Automated verification completed on macOS:

  • npm run build && npm run typecheck — passed.
  • ACP bridge unit suite — 485/485 passed.
  • Session unit suite — 533/533 passed.
  • ACP agent unit suite — 383/383 passed.
  • Focused deep-health aggregation tests — 13/13 passed.
  • Focused daemon startup deep-health test — 1/1 passed.
  • Pre-commit Prettier and ESLint checks — passed.

The full serve server test file completed with 865/867 passing. The two failures are pre-existing workspace tool-auth baseline failures unrelated to this diff: passes client identity into the bridge expected 200 and received 401, and 400 invalid_client_id... expected 400 and received 404.

The manual daemon restart scenario has not been executed in this local environment. The prepared E2E plan covers the released-build baseline, activePrompts: 0 with activeWork: true while a background Agent remains active, terminal-notification continuation, FIFO hand-off, managed/draining workspace aggregation, per-Session heartbeat loss, legacy-child compatibility, and the unchanged shallow health response.

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 12adf86, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

health-deep-with-session

field PR base (before) this PR (after)
activeWork true
activeWorkReporting "partial"
activeWorkStaleMs 0

Qwen Code · serve A/B

doudouOUC and others added 3 commits August 6, 2026 08:03
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Reworks the active-work signal after review. Three changes of substance.

Drops the 45s heartbeat watchdog entirely. It inferred "this channel is
dead" from "one Session stopped reporting" and killed the whole channel,
taking every Session on that process with it — including on a suspend,
a long event-loop stall, or a single dropped notification. Channel
liveness is a transport concern and gets its own mechanism.

Replaces the per-Session boolean with a channel-wide snapshot of named
holds, derived on every report from the owners of the work (the
registry's unfinalized set, the notification queue) rather than from a
ledger kept alongside them. Full snapshots make a dropped report
self-correcting in both directions, and a Session's absence from one is
positive evidence the child released it. Agent holds now use
hasUnfinalizedTasks()'s predicate, closing the cancel to
finalizeCancelled() window where a cancelled agent looked idle and its
terminal notification could be stranded.

Leaves prompts out of the child's report: the daemon accepts, queues,
dispatches, and settles them, so its own count is authoritative and
covers the FIFO wait the child cannot see. A snapshot is flushed ahead
of the prompt response so a hold the prompt left behind is on the wire
before the daemon drops that count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the active-work rework with the two facts a restart controller
was still missing and the one guarantee automatic cleanup was missing.

Automatic cleanup no longer destroys a Session on the strength of a
cached snapshot. It asks the child to close only if unheld, and the
child answers under its own close gate — with the gate held no prompt is
admitted and no automatic turn starts, so a hold cannot appear between
the check and the teardown. A refusal hands back the current holds and
the daemon adopts them. An unanswered request is neither retried nor
assumed: the Session stays, and the next snapshot settles it, because a
Session absent from one has provably been released. Every automatic path
— detach, attach rollback, prompt settle, notification settle, a child
reporting itself idle — now funnels through one decision point instead
of four near-copies.

Health gains activeWorkReporting and activeWorkStaleMs. Without them
activeWork:false cannot be told apart from "no child told me anything",
which is the one case where acting on it is unsafe. Freshness is graded
by the daemon rather than the controller, since the cadence is negotiated
per channel; a stale snapshot or a child omitting a category degrades the
grade instead of silently narrowing what the boolean covers.

Tests: acp-bridge 489/489, acpAgent 383/383, Session 534/534,
serve suites 1188 with one pre-existing cross-file flake in the Live
Appshot integration tests (reproduces on the unmodified tree, failing a
different test each run).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@doudouOUC
doudouOUC force-pushed the agent/serve-active-work-state branch from 5d145ae to 612bcb7 Compare August 6, 2026 04:42
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Force-pushed: the design changed, not just the code

Rebased onto current main (this PR was conflicting) and reworked in response to review. If you looked at the previous revision, the mechanism is different now — please re-read rather than diffing against memory. The PR body has been rewritten to match.

Removed: the per-Session boolean heartbeat and the 45s channel recycle. Inferring channel death from one Session's silence kills every Session on that process, and a host suspend, a long event-loop stall, or a single dropped notification are all indistinguishable from a wedged child. Transport liveness is now its own layer (new PR 2 in #8586).

Replaced with: channel-wide full snapshots of named holds, derived on every report from the owners of the work rather than kept in a parallel ledger; a three-state daemon cache that distinguishes "never negotiated" from "not yet heard from"; and a conditional close where the child confirms under its own close gate before anything is destroyed.

Added: activeWorkReporting and activeWorkStaleMs, so activeWork: false can be told apart from "no child reported". The busy rule for controllers is three terms now, not two.

Concrete bug fixed along the way: agent holds key on hasUnfinalizedTasks(), not hasRunningTasks(). A cancelled Agent still owes its terminal notification, and the previous predicate made a detached Session look idle for that whole window — reaping it and stranding the notification.

Full reasoning: #8586 (comment)

Please note the testing status section in the body: unit suites are green, but end-to-end has not been run and none of the seven reviewer test items were executed manually.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed the current force-pushed implementation at 612bcb7. The full-snapshot hold model and fail-closed reporting direction look sound, but the inline findings below still leave one unsafe automatic-close path, a health aggregation edge case, and a red test suite. Current failing CI: https://github.com/QwenLM/qwen-code/actions/runs/31071986719/job/92521662522

Comment thread packages/acp-bridge/src/bridge.ts Outdated
Comment thread packages/cli/src/acp-integration/session/Session.ts Outdated
Comment thread packages/cli/src/acp-integration/active-work-reporter.ts Outdated
Comment thread packages/cli/src/serve/routes/health-demo.ts Outdated
doudouOUC and others added 2 commits August 6, 2026 15:27
…ion mocks

CI caught two things the local runs missed.

The reporter's snapshot construction was unguarded. Only the send was
wrapped, so a throw while collecting a Session's holds escaped through
setInterval and queueMicrotask as an uncaught exception — capable of
taking down the ACP child — and through flush() into the prompt path,
turning a reporting problem into a failed prompt. Collection is now
wrapped and a failed snapshot is abandoned whole rather than sent
partially: a Session missing from a report reads as released, and one
reported with no holds reads as safe to close, so publishing a partial
snapshot would actively invite the daemon to destroy live work. Sending
nothing lets the daemon's copy age instead, which its freshness grading
already treats as untrustworthy and retains. flush() no longer rejects.

Session.review-lease and Session.worktree mock the background-task
registry without setStatusChangeCallback, so constructing a Session threw.
That break arrived with the original commit, which verified only
Session.test.ts; the sibling Session.*.test.ts files were never run. Both
mocks now carry the methods the constructor and the hold collector need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lures

The previous commit added the guard but could not have demonstrated it:
the same commit also gave the acpAgent Session mock a
collectActiveWorkHolds, removing the very condition that triggered the
throw. The unhandled error disappearing was therefore explained by the
mock alone, and active-work-reporter.ts had no tests at all.

These cover the escape routes that matter — the interval timer, the
coalescing microtask, and flush() on the prompt path — plus the choice to
abandon a whole snapshot rather than send a partial one, since a session
omitted from a report reads as released and one reported with no holds
reads as safe to close.

Verified by removing the guard: five of the nine fail with the collection
error escaping, and pass again once it is restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@doudouOUC

doudouOUC commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Self-audit: three things to fix before this leaves draft

I re-audited this adversarially. The read side — the part the title is about — holds up. The write side does not, and the three problems below share one root cause, so they should be fixed as one change rather than three patches.

Since this is a draft I am not calling these merge blockers, but they are design defects rather than unfinished work: the functions involved are all written, and it is the guard model itself that does not hold.

What is actually fine

The exposed surface is a whitelist projection of three scalars on GET /health?deep=1 — no object serialization, no absolute paths, no $HOME, no prompt text, no tool arguments, no tokens. Hold payloads ({category, id}) stay in the daemon's in-memory SessionEntry.childHolds and never reach an HTTP response. Auth reuses the existing /health tiering, and the CORS deny-wall plus host allowlist are registered ahead of the pre-auth /health route, so page JS cannot read it. Scope matches the existing sessions / activePrompts fields — process-global aggregation, not a new scope. collectActiveWorkHolds() is derived per call rather than a ledger, so it does not grow with session lifetime. Cleanup is sound: closeSessionImpl deletes the entry and all three getters fall to zero.

The root cause

This PR promotes a cached child-reported snapshot from a hint into the authority that permits destroying a session. Three destruction paths now consult it, their guards are inconsistent with each other, and every one of them is weaker than what main had.

1. Snapshot absence tears down sessions a user is actively watching. The absence loop checks only activeWorkCloseInFlight and entryHasLocalWork. It does not check entry.events.subscriberCount or entry.clientIds.size — and maybeCloseIdleSession treats both as hard guards. So a session with a live SSE subscriber and a registered client is destroyed because one snapshot omitted it.

This also contradicts the PR description directly. I wrote that an unreported or unconfirmed Session is retained, never destroyed. bridge.test.ts:406-428 encodes the opposite as expected behaviour: the session is never detached, its spawn-owner clientId is still registered, and the test asserts sessionCount goes to 0 after an empty snapshot. The description claims a guarantee the test proves absent. That wording has to change or the behaviour does — currently they disagree, and the test would keep the wrong one honest.

2. A ten-second TOCTOU window that main does not have. confirmChildUnheld is a withTimeout(..., ACTIVE_WORK_CLOSE_TIMEOUT_MS) of 10 seconds, and it never sets entry.closing. So between the last guard and closeSessionImpl setting closing = true there is a window up to ten seconds wide, and attach, sendPrompt and rewindSession all gate on closing alone. On main the detach path was a synchronous guard sequence followed directly by closeSessionImpl, whose first await comes after closing is set — the transition was atomic. I split it. A client that attaches inside the window gets its session destroyed out from under it, with the prompt lost.

3. Arbitrarily stale cache authorizes reaping. entryHasActiveWork never looks at childHoldsAt. Staleness is implemented — activeWorkReporting compares against intervalMs * ACTIVE_WORK_STALE_INTERVALS — but only to report health, never to gate destruction. So a child that goes silent after emitting one empty snapshot leaves a cache that permits reaping indefinitely, even if it has since restarted a background agent.

Fix direction

One shared helper for "may this session be destroyed", with a single in-flight flag and a single freshness gate, used by all three paths. Concretely: add the in-flight gate before confirmChildUnheld and have attach/sendPrompt/rewindSession check it; give the absence loop the same subscriber/client guards maybeCloseIdleSession uses, and recover a lost close response by re-sending sessionClose{onlyIfUnheld} for that one session instead of treating absence as consent; make entryHasActiveWork fall back to the conservative childHolds === null semantics once the snapshot is stale.

Also worth noting: confirmChildUnheld's comment argues that a hold cannot appear between check and teardown while the gate is held. That is true on the child side and I verified it, but it says nothing about the daemon-side window in item 2, and a reader will take it as covering the whole path. It should say what it does not cover.

Smaller items, not part of the above

  • A runtime with zero sessions reports 'full', which sets reportingAny and makes a mixed deployment aggregate to 'partial' when real coverage is zero. No test can currently produce an aggregate 'none'.
  • activeWorkOldestReportAt does not use the covered predicate that activeWorkReporting uses, so with sessions present but all stale it returns a positive activeWorkStaleMs while activeWorkReporting says 'none'. Three places document the opposite ("0 when nothing is covered").
  • setStatusChangeCallback is a single slot, and Session now takes it unconditionally and clears it to undefined on dispose even if someone else owned it. Harmless under ACP today because the TUI is absent, but it should be an add/remove listener pair.
  • parseActiveWorkSnapshot validates every field type and discards the whole packet on malformation (correct), but caps neither sessions[] nor holds[] length. Zero-cost to add.
  • activeWork only walks byId, so it reports false while hasNoChannelWork is simultaneously refusing to kill the channel for in-flight spawns, pending restores, or MCP discovery. A field whose stated purpose is "can this daemon be reclaimed" should not disagree with the daemon's own answer.

@doudouOUC doudouOUC self-assigned this Aug 7, 2026
Self-review of the previous revision found that this PR had promoted a
cached child report from a hint into the authority that permits destroying
a Session. Four teardown paths consulted it, their guards disagreed with
each other, and each was weaker than what main had. The four are one
defect with four exits, so they are fixed as one change.

Absence from a snapshot no longer authorizes teardown. Because reports
are complete, a Session the child omits holds nothing on the child side —
so absence and reported-with-no-holds are the same fact and now take the
same path. The separate absence loop is gone; it lacked the subscriber
and client guards `maybeCloseIdleSession` applies, so one snapshot could
destroy a Session with a live SSE subscriber and a registered client.
That contradicted this PR's own claim that an unreported Session is
retained, and the old test asserted the destruction. Both are corrected.

A conditional close is now marked in flight across the whole confirm-then-
teardown span, and attach, prompt, and rewind refuse a Session in that
state exactly as they refuse one already closing. `closeSessionImpl` sets
`closing` synchronously, but the round trip in front of it is an await of
up to ten seconds; on main the guard sequence ran straight into teardown,
so splitting it is what opened the window.

A snapshot older than the freshness window stops counting as evidence.
Staleness was already computed, but only to grade health, never to gate
destruction — so a child that went quiet after one empty report left a
cache that permitted reaping indefinitely. Never-reported and gone-quiet
now land in the same retained bucket. Reclaiming a channel that has truly
stopped answering belongs to transport liveness, not here.

The idle reaper asks the child too. Its TTL says the client stopped
caring, which is not the same as the child having nothing left to run.

Health coverage is exposed as counts and graded once daemon-wide, because
grades do not compose: a runtime with zero Sessions is vacuously `full`,
and folding that in let an empty workspace vouch for another workspace's
unreported Sessions. `activeWorkStaleMs` now measures only covered
Sessions, so it can no longer report positive staleness beside a grade
saying nothing is covered.

Also: bound snapshot `sessions[]` and `holds[]` so a buggy child cannot
make the daemon walk an unbounded structure per report, and retract the
background-task status callback by identity rather than blanking a
single-slot setter the TUI also uses.

Tests: the absence test now asserts retention under a registered client
and under a live subscriber; new regressions cover the recovered lost
close response, the stale-snapshot gate, admission refusal during a
conditional close, the reaper's confirmation, the oversized-snapshot
discard, and the mixed empty/uncovered health aggregate.
@doudouOUC
doudouOUC marked this pull request as ready for review August 7, 2026 15:38
@doudouOUC
doudouOUC enabled auto-merge August 7, 2026 15:38
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Re-run over the new head 12adf86 — one fix commit on top of the previously reviewed 80019ac: "close three teardown races the confirm window opened", which is a direct response to the three Critical findings from /review round 1. The gate assessment is unchanged in substance; the size numbers move slightly.

  • Template: complete ✓
  • Problem: real and tracked, not theoretical. Umbrella issue Track activeWork and background Agent recovery #8586 (roadmap/background-automation) defines this exact stage, and the failure mode is concrete: activePrompts drops to zero when the foreground prompt settles while background Agents it spawned are still running, so a restart controller can restart the daemon mid-work. This PR is the issue's designated PR 1 of 5.
  • Direction: aligned, unchanged. activeWork remains an observation cache with an explicit reporting grade rather than a restart lease; there is no heartbeat-driven channel kill; the description stays honest about what the signal does not prove.
  • Size: 20 files, +2650/−122. Of that: 1298 lines production logic (acp-bridge protocol + cli reporting/cleanup + one additive method in packages/core), 1326 lines tests, 148 lines docs. Growth since the last pass is the three teardown-race fixes and their regression test — defect fixing inside the existing scope, not new scope. It touches core paths and spans three packages, so per the core-module policy this stays flagged for maintainer awareness (feat-type PRs aren't blocked on size; the 500+ production-line escalation and the 1000+ large-PR advisory both apply). Splitting still doesn't look practical — the umbrella issue planned this as one coherent stage.
  • Approach: scope is still exactly stage 1 of Track activeWork and background Agent recovery #8586; layers 2–5 stay deferred. The new commit is +39/−2 in bridge.ts plus a 41-line regression test — one predicate-level exclusion, one identity re-check, one admission-predicate upgrade across the three restore-path guards. No unrelated edits spotted.
  • Risk: the packages/cli/src/acp-integration/ paths are in this repo's revert-correlated set, so this keeps the full review depth and CI evidence bar. CI is green at the new head (see Stage 2), and the sandboxed verification lane is running in parallel with this triage run — its report will land in this thread.

Moving on to code review. 🔍 Because of the core-size escalation above, final approval needs a maintainer's sign-off regardless of how the review lands — that's policy, not a judgment on the code. (One already exists at exactly this head — see Stage 3.)

中文说明

本次 re-run 针对新的 head 12adf86 —— 在之前审查过的 80019ac 之上有一个修复 commit:"close three teardown races the confirm window opened",直接回应 /review 第一轮提出的三个 Critical 发现。门禁结论实质不变,仅规模数字略有更新。

  • 模板: 完整 ✓
  • 问题: 真实且已被跟踪,不是理论性问题。Umbrella issue Track activeWork and background Agent recovery #8586roadmap/background-automation)明确定义了本期内容,故障模式具体:前台 prompt settle 后 activePrompts 归零,而它拉起的后台 Agent 仍在运行,重启控制器可能因此在工作进行中重启 daemon。本 PR 是该 issue 规划的 5 个 PR 中的第 1 个。
  • 方向: 对齐,不变。activeWork 仍是带明确上报分级的观测缓存而非重启租约;没有基于心跳的 channel kill;描述对这个信号不能证明什么依旧诚实。
  • 规模: 20 个文件,+2650/−122。其中生产逻辑 1298 行(acp-bridge 协议 + cli 上报/清理 + packages/core 一个增量方法),测试 1326 行,文档 148 行。相比上次审查的增长来自三个 teardown 竞态修复及其回归测试 —— 是既有范围内的缺陷修复,不是新范围。触及 core 路径且跨三个包,按核心模块策略继续标记为需维护者关注(feat 类 PR 不因规模被阻塞;500+ 生产行升级与 1000+ 大 PR 提示均适用)。拆分仍不现实 —— umbrella issue 有意把本期规划为一个完整阶段。
  • 方案: 范围仍恰好是 Track activeWork and background Agent recovery #8586 的第 1 期,第 2–5 层保持推迟。新 commit 是 bridge.ts +39/−2 加一个 41 行回归测试 —— 一处谓词级排除、一处身份复查、三处 restore 路径守卫统一升级到准入谓词。未发现无关改动。
  • 风险: packages/cli/src/acp-integration/ 路径在本仓库的回滚相关路径集合中,因此保持完整 review 深度与 CI 证据标准。新 head 上 CI 全绿(见 Stage 2),沙箱验证通道正与本次 triage 并行运行,报告会发布在本线程。

进入代码审查。🔍 由于上述 core 规模升级,无论 review 结果如何,最终批准都需要维护者签字 —— 这是策略要求,不是对代码的否定。(事实上本 head 上已有一个维护者批准 —— 见 Stage 3。)

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

Re-reviewed at 12adf86 — the full diff, with close attention to the one commit landed since the last pass (12adf86, "close three teardown races the confirm window opened"). The short version: all three Critical findings from /review round 1 are genuinely fixed in the final tree, the fixes are minimal and structural rather than patch-by-patch, and I found no new blocking issues.

Critical 1 — a restore in flight looked exactly like an abandoned Session. Fixed at the right layer. entryIsAutoCloseCandidate now excludes any entry whose owning channel has it in pendingRestoreIds. I traced the lifecycle in the final tree: the id is added before the ACP loadSession/resumeSession call and the artifacts.restore() / seedSessionUpdates() awaits, and only removed in the restore IIFE's finally — which runs after registerClient has attached the first client, so there is no gap between "exclusion lifted" and "client present". Placing the exclusion in the shared predicate rather than at the snapshot trigger means the reaper's TTL elapsing inside a slow restore is covered by the same check.

Critical 2 — teardown re-resolved the target by id after the confirm round trip. Fixed with one identity re-check (byId.get(entry.sessionId) !== entry) between confirmChildUnheld and closeSessionImpl, exactly where the stale-continuation hole was. The kill-then-reload interleave that motivated it — kill ignores the in-flight flag by design, and a fresh session/load can re-register the same id during the round trip — can no longer be torn down by the stale continuation. The finally clearing activeWorkCloseInFlight touches only the old entry; the replacement is unaffected.

Critical 3 — the restore path wasn't upgraded to the new admission predicate. Fixed at all three guards in restoreSession (entry found, post-await re-check, and the racedEntry branch, which previously had no closing guard at all): all now use isClosingOrAuthorizingClose. With sendPrompt, rewindSession, and attach already on the same predicate, the closeIfChildUnheld doc comment claiming every admission path checks the flag is now actually true. A 41-line regression test pins the restore-path refusal while a conditional close is in flight; the other two fixes need a mid-restore snapshot and a kill-then-reload interleave the mocked-channel harness cannot stage honestly, and the commit message says so plainly — they rest on code-path reading, which is weaker and noted as such.

The nine round-1 Suggestions are explicitly deferred by the author — recorded thread-by-thread as "defer, not dispute", consistent with this repo's guidance to stop widening scope after ~5 review rounds. They remain valid follow-ups: the missing enqueueBackgroundNotification / setSessionApprovalMode / setSessionModel admission guards, the unbounded refusal-response hold set, the seq high-water latch, the eager-publish comment mismatch, the dead intervalMs property, and the two doc-drift items. None of them is a correctness blocker for the restart-safety claim this PR makes.

Everything said in the previous pass about the base of this PR still holds — the three-state model, the single-funnel retention rule, bounded snapshots, daemon-wide grading, and the conventions (no any, no lint overrides, colocated tests, docs with code).

sequenceDiagram
    participant D as Daemon bridge
    participant C as ACP child
    participant R as ActiveWorkReporter
    participant S as Session
    D->>C: initialize - proposes capability in _meta
    C->>R: creates reporter with clamped cadence
    C-->>D: answers with cadence and covered categories
    R->>D: full channel-wide snapshot (interval, on change, on flush)
    Note over D: absence and no-holds are the same fact
    D->>D: candidate check - shared guards, restore-in-flight excluded
    D->>C: conditional close ask, marked in-flight
    Note over D: attach, prompt, rewind, restore refuse in-flight
    D->>D: identity re-check before teardown
    C->>S: checks holds under its close gate
    S-->>C: no holds - or refusal handing back holds
    C-->>D: closed true, or refusal, or silence (retain)
Loading
Files changed (20 of 20 shown)
File What changed
packages/acp-bridge/src/bridge.ts Heart of the PR: per-session hold cache, three-state model, shared candidacy predicate and single closeIfChildUnheld decision span; the latest commit adds the restore-in-flight exclusion, the post-confirm identity re-check, and the restore-path admission guards
packages/acp-bridge/src/bridgeTypes.ts Protocol constants and types: capability meta key, cadence clamp, snapshot bounds, gradeActiveWorkCoverage, and the activeWorkCoverage member on AcpSessionBridge
packages/acp-bridge/src/bridgeClient.ts Child-side snapshot handler: validates and applies channel-wide snapshots with the size bounds
packages/acp-bridge/src/bridge.test.ts Active-work suite: negotiation, grading, malformed and oversized snapshots, retention cases, ask-on-unknown both ways, stale gate, admission refusal incl. the new restore-path case, reaper confirmation
packages/cli/src/acp-integration/acpAgent.ts Negotiates the capability in initialize, owns the per-channel reporter, threads onlyIfUnheld through session close, flushes a snapshot ahead of prompt responses
packages/cli/src/acp-integration/active-work-reporter.ts Channel-wide snapshot publisher with monotonic seq, microtask coalescing, serialized sends, fail-closed collection
packages/cli/src/acp-integration/active-work-reporter.test.ts Reporter unit suite including failure-containment cases
packages/cli/src/acp-integration/session/Session.ts Derives holds from registry and notification state, wires lifecycle callbacks to the reporter, retracts its status callback by identity on dispose
packages/cli/src/acp-integration/session/Session.test.ts Hold-derivation tests plus the identity-retraction assertion
packages/cli/src/acp-integration/session/Session.review-lease.test.ts Registry mock extended with clearStatusChangeCallback
packages/cli/src/acp-integration/session/Session.worktree.test.ts Registry mock extended with clearStatusChangeCallback
packages/cli/src/acp-integration/acpAgent.test.ts Negotiation-merge test for initialize
packages/cli/src/serve/routes/health-demo.ts Deep-health aggregation: sums coverage counts across runtimes and grades once daemon-wide, staleMs over covered sessions only
packages/cli/src/serve/server.test.ts Deep-health assertions incl. the wedged-getter case and the empty-workspace-must-not-vouch aggregate
packages/cli/src/serve/multi-workspace-sessions.test.ts Fake bridge extended with activeWorkCoverage
packages/cli/src/serve/run-qwen-serve.test.ts Fake bridge extended with activeWorkCoverage
packages/core/src/agents/background-tasks.ts Two additive read/write members: listUnfinalizedBackgroundAgentIds sharing the hasUnfinalizedTasks predicate, and identity-checked clearStatusChangeCallback
docs/design/2026-08-06-active-work-health.md Design doc: one guard model four triggers, unknown asks, daemon-side in-flight cover
docs/design/daemon-global-deep-health.md Existing design doc updated for the new fields
docs/developers/qwen-serve-protocol.md Protocol docs: session-scoped semantics, freshness window, daemon-wide grade

Testing

All checks green on 12adf86 — including Test (ubuntu-latest, Node 22.x) and Serve A/B (ubuntu-latest, Node 22.x), the latter re-run against this exact head and still reporting exactly the three promised additions on health-deep-with-session with no other response drift, so the public wire shape is independently pinned at the reviewed commit again. No failures at this head. Not verified here: live end-to-end lifecycle against a real child process (see the verify lane below), and macOS/Windows behavior (platform unit checks are skipped in this repo's CI).

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
Real daemon E2E / Java 11 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Classify PR ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (CLI, No Sandbox) ⏭️ skipped

The central claims are live-lifecycle behaviours — retention across a restore in flight, the conditional-close handshake including refusal and unanswered paths, admission refusal (now on the restore path too), the identity re-check across the confirm window — and the unit suite pins them only through mocked channels, with two of the three new fixes resting on code-path reading by the author's own account. Sandboxed verification is the lane that settles this: @qwen-code /verify — triggered by this triage run and in flight right now; its report will post in this thread when it finishes. One caution for whoever reads this before merge: the earlier "✅ passed — merge-ready" verification comment in this thread carries a report that is actually about PR 8570 (a VirtualizedList height-cache fix), not this PR — it should not be cited as evidence here. @qwen-code /tmux is not applicable — no TUI surface changes.

中文说明

代码审查

已在 12adf86 重新审查 —— 完整 diff,重点放在上次审查之后落地的一个 commit(12adf86,"close three teardown races the confirm window opened")。简述:/review 第一轮的三个 Critical 均已在最终代码中真正修复,修法最小且是结构性的而非逐点打补丁,未发现新的阻塞问题。

Critical 1 —— 进行中的 restore 看起来与被遗弃的 Session 完全一样。 在正确的层面修复。entryIsAutoCloseCandidate 现在排除所属 channel 的 pendingRestoreIds 中包含的条目。我在最终代码中追溯了生命周期:id 在 ACP loadSession/resumeSession 调用与 artifacts.restore() / seedSessionUpdates() await 之前加入,直到 restore IIFE 的 finally 才移除 —— 而 finallyregisterClient 挂上第一个 client 之后运行,因此"排除解除"与"client 就位"之间没有空窗。排除放在共享谓词而非快照触发点,意味着 reaper TTL 在缓慢 restore 中到期也被同一检查覆盖。

Critical 2 —— 拆除在确认往返后按 id 重新解析目标。 以一处在 confirmChildUnheldcloseSessionImpl 之间的身份复查(byId.get(entry.sessionId) !== entry)修复,正是陈旧续延漏洞所在。kill 后重新 load 的交错场景 —— kill 按设计无视 in-flight 标志,往返期间同一 id 可被新 session/load 重新注册 —— 不再会被陈旧续延拆掉。finally 清除 activeWorkCloseInFlight 只作用于旧条目,替换条目不受影响。

Critical 3 —— restore 路径未升级到新的准入谓词。 restoreSession 的三处守卫(发现已有条目、await 后复查、racedEntry 分支 —— 最后一处此前完全没有关闭守卫)全部改用 isClosingOrAuthorizingClose。加上已经使用同一谓词的 sendPromptrewindSession 与 attach,closeIfChildUnheld 文档注释声称"每条准入路径都检查该标志"现在真正成立。41 行回归测试固定了条件关闭进行中 restore 路径的准入拒绝;另两个修复需要快照恰在 restore 中途到达、以及 kill-then-reload 交错,mock channel 测试装置无法诚实地构造,commit message 直言不讳 —— 它们依赖代码路径阅读,较弱,已如实标注。

第一轮的九个 Suggestion 由作者明确推迟 —— 在每个线程中记录为"推迟,不反驳",符合本仓库约 5 轮审查后不再扩大范围的指引。它们仍是有效的后续项:缺失的 enqueueBackgroundNotification / setSessionApprovalMode / setSessionModel 准入守卫、无界的拒绝响应 hold 集合、seq 高水位闩锁、eager-publish 注释不符、死代码 intervalMs 属性,以及两处文档漂移。均不构成本 PR 重启安全声明的正确性阻塞项。

上次审查对本 PR 基础部分的所有结论仍然成立 —— 三态模型、单漏斗保留规则、有界快照、daemon 级分级,以及约定(无 any、无 lint 豁免、测试与源码同目录、文档与代码同 commit)。

测试

12adf86 上所有检查全绿 —— 包括 Test (ubuntu-latest, Node 22.x)Serve A/B (ubuntu-latest, Node 22.x);后者在本 head 上重跑,结果仍恰好是承诺的三个新增字段且无其他响应漂移,公开线上形状再次在被审查 commit 上得到独立固定。本 head 无失败。此处未验证:针对真实子进程的端到端生命周期行为(见下方 verify 通道),以及 macOS/Windows 行为(平台单测在本仓库 CI 中被跳过)。

核心声明是生命周期行为 —— restore 进行中保留、条件关闭握手(含拒绝与无应答)、准入拒绝(现在包括 restore 路径)、确认窗口上的身份复查 —— 单测只能通过 mock channel 固定,且三个新修复中有两个按作者自己的说法依赖代码路径阅读。沙箱验证是坐实它的通道:@qwen-code /verify —— 已由本次 triage 触发,正在运行,完成后报告会发布在本线程。一点提醒:本线程中较早的 "✅ passed — merge-ready" 验证评论所载报告实际上是 PR 8570(VirtualizedList 高度缓存修复)的,不属于本 PR —— 合并前请勿将其作为本 PR 的证据引用。无 TUI 表面变更,/tmux 不适用。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the delta review at the new head is clean: all three round-1 Criticals are verifiably fixed in the final tree, CI is green at exactly this commit, and a maintainer approval already stands on it; the cap is pure core-size policy, which keeps the bot out of the final sign-off on a 1298-line fork PR touching core paths.

Stepping back: what this round demonstrates is the PR's best habit. Three race findings — the kind that invite a scatter of point patches — got one shared diagnosis ("the round trip turned a synchronous guard-then-teardown into an awaited span"), three minimal fixes at exactly the points the diagnosis names, and a commit message that tells you which two fixes the test harness cannot honestly pin and why. The restore-in-flight exclusion went into the shared candidacy predicate rather than the snapshot trigger, so the reaper's path inherits it for free — that is the difference between fixing a finding and fixing the class. The identity re-check sits exactly between the confirm await and the re-resolving teardown. The restore path now uses the same admission predicate as every other entry point, making the doc comment that previously overpromised actually true. Scope discipline holds: +39/−2 production lines, one regression test, nothing else touched; layers 2–5 of #8586 stay out.

My independent baseline for this problem (additive deep-health fields, versioned _meta handshake, full self-healing snapshots, cleanup that confirms with the child) still matches what the PR does. CI is green at 12adf86, Serve A/B pins the wire shape at exactly this commit, and wenshao approved this exact head at 03:18 UTC today.

What remains, plainly stated:

  • Verification. The sandboxed /verify lane triggered by this run is still in flight; its report should be read before merge. Note that the earlier "✅ passed — merge-ready" verification comment in this thread actually carries the report for PR 8570, not this one — treat this PR as not yet sandbox-verified until the in-flight report lands.
  • Review state. The bot's own /review round-1 CHANGES_REQUESTED still formally stands on the PR even though the three Criticals it cites appear fixed at this head; a round-2 /review pass (or a maintainer dismissing the stale review) would clear it. The nine deferred Suggestions are recorded in-thread as follow-ups, per the repo's after-~5-rounds discipline.
  • Policy. A fork PR touching core paths with ~1298 production logic lines escalates for maintainer awareness under the core-module policy, so the bot does not approve it no matter how clean the review is. With a maintainer approval already on this exact commit, the human half of that gate is done — what's left is the team's merge call, ideally after the verify report.

⏸️ Deferring to @wenshao @tanzhenxin @yiliang114 @LaZzyMan/packages/core/ owners per CODEOWNERS. Review at 12adf86 found no blockers; approval already exists at this head from @wenshao. Remaining: the in-flight /verify report, and clearing or confirming the stale round-1 changes-requested state.

中文说明

置信度:3/5 —— 新 head 上的增量审查干净:第一轮的三个 Critical 均已在最终代码中核实修复,CI 恰在本 commit 上全绿,且已有维护者批准落在同一 head;封顶纯粹来自核心模块规模策略 —— 1298 行生产逻辑、触及 core 路径的 fork PR,最终签字不归 bot。

退一步看:这一轮展示的是这个 PR 最好的习惯。三个竞态发现 —— 最容易引来一堆零散补丁的那类问题 —— 得到了一个共享诊断("往返把同步的'守卫即拆除'变成了带 await 的区间")、三处恰好落在诊断所指位置的最小修复,以及一条如实说明哪两个修复无法被测试装置诚实固定、为什么的 commit message。restore 进行中的排除被放进共享候选谓词而不是快照触发点,于是 reaper 路径免费继承了它 —— 这是"修复一类问题"与"修复一个发现"的区别。身份复查恰好位于确认 await 与重新解析目标的拆除之间。restore 路径现在与其他所有入口使用同一准入谓词,使此前过度承诺的文档注释真正成立。范围纪律保持:生产代码 +39/−2,一个回归测试,未触碰其他;#8586 的第 2–5 层仍在范围外。

我对这个问题的独立基线(深度健康接口增量字段、带版本的 _meta 握手、全量自愈快照、清理前向子进程确认)与 PR 的做法仍然一致。CI 在 12adf86 全绿,Serve A/B 恰在本 commit 上固定线上形状,wenshao 已于今日 03:18 UTC 在本 head 上批准。

尚未完成的,直说:

  • 验证。 本次运行触发的沙箱 /verify 通道仍在运行,其报告应在合并前阅读。注意:本线程中较早的 "✅ passed — merge-ready" 验证评论所载实为 PR 8570 的报告,不属于本 PR —— 在运行中的报告落地之前,请将本 PR 视为尚未经沙箱验证。
  • 评审状态。 bot 自己的 /review 第一轮 CHANGES_REQUESTED 在形式上仍然有效,尽管其引用的三个 Critical 已在本 head 上修复;第二轮 /review(或维护者撤销过期评审)可以清除它。九个被推迟的 Suggestion 已按本仓库约 5 轮后的纪律在线程中记录为后续项。
  • 策略。 触及 core 路径、约 1298 行生产逻辑的 fork PR 按核心模块策略升级为维护者关注,因此无论 review 多干净,bot 都不批准。由于本 commit 上已有维护者批准,该门禁的人工部分已完成 —— 剩下的是团队的合并决定,最好在 verify 报告之后。

⏸️ 转交 @wenshao @tanzhenxin @yiliang114 @LaZzyMan(CODEOWNERS 中 /packages/core/ 的 owner)。12adf86 上的复审未发现阻塞项;@wenshao 的批准已落在本 head。剩余事项:运行中的 /verify 报告,以及清除或确认过期的第一轮 changes-requested 状态。

Qwen Code · qwen3.8-max

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

Triage review found that the design doc, the PR description, and the
comment on `entryHasActiveWork` all promised the daemon *asks* the child
about a Session it has not heard about, while no code path ever did:
`entryHasActiveWork` returns true when the child's side is unknown, and
the cleanup path returned early on exactly that. The finding predates the
guard rework and survived it unchanged.

Skipping on unknown looks like the safe direction and is in fact the worse
failure. Nothing resolves it — a Session on a channel that went quiet is
retained forever, and the idle reaper skips it too, so there is no path
out at all. Asking resolves it definitively: the child answers under its
own close gate whether or not its snapshots are arriving, the round trip
is bounded, and every non-answer still retains.

So the predicate is split by what it actually knows. `childReportsHeldWork`
is positive knowledge only; `childWorkIsUnknown` is the absence of a
gradeable report. The health surface ORs both, because a controller must
never read "nobody told me" as "nothing is running". Automatic cleanup
blocks only on known work and lets unknown through to `confirmChildUnheld`.

Also moves `parseActiveWorkSnapshot` out from between two import blocks
(pure relocation, no logic change) and aligns the doc wording, including
the shared-guard table, with what the code now does.
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Thanks — both findings taken, and one correction to the record: this review is against fb68eeb53, but that commit is not the current state of the work. An adversarial self-audit after it turned up three defects in the write side that the review's "safety posture is consistent throughout" reading does not cover, so the fixes below are on top of a guard rework, not a patch to the tree you read.

Finding 1 — the "unknown" state promises an ask the code never made. Correct, and it survived the rework unchanged. Fixed by splitting the predicate on what it actually knows rather than by softening the wording: childReportsHeldWork is positive knowledge only, childWorkIsUnknown is the absence of a gradeable report. The health surface ORs both (a controller must never read "nobody told me" as "nothing is running"); automatic cleanup blocks only on known work and lets unknown through to confirmChildUnheld.

Worth stating why the wording was not the thing to change. Skipping on unknown looks like the safe direction and is in fact the worse failure — nothing resolves it, the reaper skips it too, and a Session on a channel that went quiet is retained forever with no path out. Asking resolves it definitively: the child answers under its own close gate whether or not its snapshots are arriving, the round trip is bounded, and every non-answer still retains. Two regression tests cover it: the ask happens for a never-reported Session, and a refusal resolves the unknown toward retention with the reason attached.

Finding 2 — parseActiveWorkSnapshot between two import blocks. Fixed, pure relocation.

What the review could not have seen. The self-audit found that this PR had promoted a cached child report from a hint into the authority that permits destroying a Session, across four teardown paths whose guards disagreed with each other:

  1. Absence from a snapshot tore down a Session with a live SSE subscriber and a registered client — guards maybeCloseIdleSession treats as hard. That contradicted this PR's own "unreported or unconfirmed is retained, never destroyed" claim, and bridge.test.ts asserted the destruction as expected behaviour.
  2. confirmChildUnheld is a 10s round trip that never set closing, so attach/prompt/rewind could be admitted into a Session already authorized for teardown. On main that sequence was synchronous; splitting it opened the window.
  3. Staleness was computed but only ever used to grade health, never to gate destruction — so a child that went quiet after one empty report left a cache that permitted reaping indefinitely.
  4. The idle reaper did not call confirmChildUnheld at all.

All four are the same defect with four exits, so they are fixed as one change: one shared candidacy predicate, one in-flight flag held across confirm-then-teardown, one freshness gate, and the reaper routed through the same ask. The absence loop is gone — because reports are complete, absence and reported-with-no-holds are the same fact and now take the same path.

On the two deferred items: agreed that /verify is the right instrument, since the central claims are live-lifecycle behaviours that the unit suite pins only through mocked channels — the earlier "E2E test report" comment on this PR was automated verification, not a live lifecycle exercise, and I have not run one. macOS/Windows remain untested. Neither the docs/comment mismatch nor the four items above would have been caught by CI, which is the honest argument for the sandboxed lane before merge.

Re-review will need to happen at the new head rather than fb68eeb53.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Self-audit + triage findings addressed — head is now 41853db8

Two commits on top of fb68eeb53 (appended, not force-pushed, so existing review comments stay anchored). Everything reviewed at fb68eeb53 predates them.

The root cause worth stating plainly

This PR had promoted a cached child report from a hint into the authority that permits destroying a Session. Four teardown paths consulted it, their guards disagreed with each other, and every one was weaker than what main had. They are one defect with four exits, so they are fixed as one change rather than four patches.

# Defect Fix
1 Absence from a snapshot tore down a Session with a live SSE subscriber and a registered client — guards maybeCloseIdleSession treats as hard Separate absence loop deleted. Because reports are complete, absence and reported-with-no-holds are the same fact and take the same path, through every shared guard
2 confirmChildUnheld is a 10s round trip that never set closing, so attach / prompt / rewind could be admitted into a Session already authorized for teardown One in-flight flag held across the whole confirm-then-teardown span; all three admission paths refuse it exactly as they refuse closing
3 Staleness was computed but only ever graded health, never gated destruction — a child that went quiet after one empty report left a cache permitting reaping indefinitely Freshness gate moved into the retention predicate; never-reported and gone-quiet are now the same state
4 The idle reaper never called confirmChildUnheld at all Routed through the same ask, keeping its own TTL and crash-path policy

Defect 1 also contradicted this PR's own description — it claimed an unreported or unconfirmed Session is retained, never destroyed, while bridge.test.ts asserted the destruction as expected behaviour. Both are corrected; the description is updated, and the test now asserts retention under a registered client and under a live subscriber.

Triage findings

  • "unknown promises an ask the code never made" — correct, and it survived the rework. Fixed by splitting the predicate on what it actually knows rather than by softening the wording: childReportsHeldWork (positive knowledge) vs childWorkIsUnknown (absence of a gradeable report). Health ORs both; cleanup blocks only on known work and lets unknown through to the ask. Skipping on unknown looks safe and is the worse failure — nothing resolves it, so the Session is retained forever with no path out.
  • parseActiveWorkSnapshot between two import blocks — fixed, pure relocation.

Also in scope

Snapshot sessions[] / holds[] are now bounded; health coverage is exposed as counts and graded once daemon-wide (an empty runtime is vacuously full and could vouch for another workspace's unreported Sessions); activeWorkStaleMs counts only covered Sessions; the background-task status callback is retracted by identity instead of blanking a single-slot setter the TUI shares; and activeWork's Session-scoped boundary vs channel-level work is documented rather than widened.

Verification

Check Result
acp-bridge suite 1085/1085
active-work subset 17/17, incl. 8 new regressions
Session.test.ts 534/534
core background-tasks 123/123
serve suites 1188/1189 — the one failure is a pre-existing cross-file flake (passes in isolation, fails a different test each run)
deep-health subset 14/14
tsc core / acp-bridge clean
tsc cli clean in every touched file
eslint + prettier clean

New regressions: retention under a registered client and under a live subscriber; recovery of a lost close response; the stale-snapshot gate; admission refusal during a conditional close; the reaper's confirmation; the oversized-snapshot discard; the mixed empty/uncovered health aggregate; and the ask-on-unknown path in both directions.

Still not done

No live end-to-end run. Agreed that @qwen-code /verify is the right instrument — the central claims are lifecycle behaviours the unit suite pins only through mocked channels, and note that none of the four defects above would have been caught by CI, which is the honest argument for the sandboxed lane before merge. macOS and Windows remain untested.

Syncs 46 commits of base drift. CI's 'Check voice guard mirror sync' step
runs from main and invokes `npm run check:voice-guard-sync`, a script
added alongside that step in 732f4d8 (QwenLM#8350) and absent from this
branch — so the check failed on missing-script, not on anything in this
diff. Merged rather than rebased so existing review comments stay
anchored.
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

CI: Check voice guard mirror sync was base drift, not this diff — synced in 80019aca

Test (ubuntu-latest, Node 22.x) failed on 41853db8 at step 19:

npm error Missing script: "check:voice-guard-sync"

Not a flake and not in this diff. The step and the script it calls were added together in 732f4d8 (#8350); the workflow runs from main, this branch was 46 commits behind, so the check invoked a script absent from the checkout. A rerun would have failed identically.

Merged main in rather than rebasing, so the existing review comments stay anchored — the repo's own force-push reminder is the reason. Merge was conflict-free. npm run check:voice-guard-sync now passes locally.

Re-verified on the merged tree, because main had touched many of the same files (acpAgent.ts, bridgeTypes.ts, Session.ts, health-demo.ts, background-tasks.ts, plus run-qwen-serve.ts and server.ts):

Check Result
acp-bridge suite 1110/1110 (25 files)
Session + acpAgent + reporter 929/929
serve suites 1216/1218
check:voice-guard-sync passes

The two serve failures are Live conversation runtime lifecycle — a pre-existing cross-file flake in this file, unrelated to active work. All 6 tests in that describe block pass in isolation, and the same file fails a different test on different runs.

Two interactions worth naming explicitly, since both are the class of thing a clean textual merge hides:

  • main changed AcpSessionBridge in bridgeTypes.ts too — getChildResourceSnapshot gained an optional ageMs. Independent of activeWorkCoverage; no interaction.
  • main added 184 lines to acpAgent.test.ts, the same file whose Session mock previously caused an uncaught exception in the reporter. Checked directly: none of the new tests construct a Session, and the SessionMock.prototype.collectActiveWorkHolds patch survived the merge.

Also picked up cb3dc107f (#8604), which deflakes the GlobTool external-path test that had to be reran on an earlier revision of this PR.

The three route checks reported cancelled on separate workflow runs — CI runner routing pre-empted by the newer push, no action needed; the merge push re-triggers them.

@qwen-code-ci-bot

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

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

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

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

Verification report

PR 8570 — fix(cli): report zero-height VP items so collapsed thoughts release reserved space

Verdict: merge-ready — 307/307 scripted assertions passed, 0 unexpected failures. Verified head: cab04c9c67e0256e9308289704019b586dd24010 (base 9e1b1eb49eff300f866103cacd3fcef02504270c).

中文摘要
  • 结论: merge-ready。307/307 脚本断言通过,0 个意外失败。
  • A/B 结论: 中心声明成立且由零高度上报这一改动承载。独立 harness(真实组件 + ink 渲染,无 mock)在 head 上 2/2 绿(折叠后 scrollHeight 27→3、帧无空白行);在 base 上同样两个 cell 红(折叠后高度仍为 27,空白保留)。
  • 变体矩阵: 四个 hunk(零高度上报、offset-run 回走 ×3、clampedScrollTop 渲染窗口)逐一分离,全部 load-bearing:仅零上报(V1)修好中心症状但引入 4 个 re-expand 一致性红;回走/窗口 hunk 恰好修复它们;每个突变体的 kill 集合与预测完全一致;正对照(破坏既有 END-anchor 计算)使 6 个 PR 前测试变红,证明套件有判别力。空值性检查:还原 guard 后 PR 中心测试以行为断言失败(expected 31 to be 1)。
  • 未覆盖: 逐 commit 归因(depth-2 checkout,元数据 13 个 commit 本地不可达);真实终端 E2E(PR 自述的真实组件复现 harness 未随 PR 提交);旧版 <Static> 路径除既有 renderStatic 测试外未额外验证。

Central claim + A/B

Central claim: in VP mode, collapsing an expanded thought group releases the reserved vertical space, because continuations that render nothing now report their measured zero height into the height cache (hasMeasured && height > 0hasMeasured).

Harness: pr8570-ab.test.tsx (archived in this dir), self-contained, renders the real VirtualizedList through ink-testing-library with real yoga measurement — no mocks of the unit under test. Oracles are exact getScrollState().scrollHeight numbers and frame blank-line counts. Base arm: scratch worktree at HEAD^1 with package-level node_modules mirrored (PR leaves lockfile/core untouched — git diff HEAD^1..HEAD -- packages/core package.json package-lock.json is empty; vitest aliases resolve @qwen-code/qwen-code-core to each tree's own source, realpath-asserted), so the control differs only by the PR's hunks.

cell geometry oracle head base
A container 40 ≥ content 27, all mounted expanded scrollHeight = 27 (validity) ✅ 27 ✅ 27
A collapse scrollHeight = 3, 0 blank lines, footer adjacent ✅ 3 27 (gap retained)
B container 12, bottom-stuck overflow expanded ≥ 27 (validity) ✅ 29 ✅ 27
B collapse + heal cascade scrollHeight = 3 ✅ 3 27

Witnesses: 01-ab-head-collapse-releases-height.png (head 2/2 green), 02-ab-base-gap-remains.png (base red on the collapse oracles, validity controls green). The base-vs-head expanded difference in cell B (27 vs 29) is a benign, accounted residual of the clamp-window hunk (see Findings).

Secondary claims

  1. Coherence hunks are load-bearing (walk-back of coincident-offset runs at the scroll anchor, the targetScrollIndex anchor, and the render-window start; render window computed from clampedScrollTop). Mutation matrix (03-mutation-matrix-all-hunks-load-bearing.png):
build suite result red tests (all predicted)
head (all hunks) 29/29 + harness 3/3
V2 = head − zero-report 23/31 8 central-axis (harness ×2, zero-shrink, releases-reserved, off-screen-collapse, heal-1pass, blank-frame, mid-run-target)
V1 = base + zero-report only 27/31 4 coherence (heal-1pass, anchors-first-item, blank-frame, mid-run-target)
m-walkback (helper := identity) 26/29 heal-1pass, anchors-first-item, mid-run-target
m-window (window := actualScrollTop) 27/29 blank-frame, heal-1pass
m-target (target anchor, no walk-back) 28/29 mid-run-target
m-posctrl (pre-PR END-anchor math broken) 23/29 6 pre-PR bottom-stuck tests (positive control)

Reading: zero-report alone fixes the reported gap but breaks re-expand coherence; the walk-back/clamp hunks heal exactly those. Every kill set matched its prediction; no off-target reds. anchors-first-item survives V2 only because its trigger (cached zeros) cannot exist without the guard, and m-walkback kills it where the trigger exists — trigger-absence, not a coverage gap.

  1. No regressions on the VP surface: VirtualizedList suite 29/29 at head; sibling suites ScrollableList (14) + MainContent (19) + HistoryItemDisplay (36) + ConversationMessages (22) = 91/91; tsc --noEmit clean; eslint clean on both touched files.

Findings (non-blocking)

  1. Residual delta from the clamp-window hunk (accounted, benign). Bottom-stuck with overflow, the expanded steady state is 29 at head vs 27 at base: base windows the first pass from the anchor-based actualScrollTop (3, before the answer's height is known) and measures the top user row; head windows from clampedScrollTop (6) and keeps its estimate. Both states are measured-consistent and both collapse to 3 at head; base never releases (27 retained). No user-visible divergence beyond the fix itself.
  2. In-window-only reporting caveat is real and documented. A collapse of items outside the render window leaves their cached heights stale until they scroll back (my cell B exercises the remount-heal cascade; the PR's off-screen test pins it). This is symmetric with the pre-existing grow direction and is stated in the fix-site comment — advisory, not a defect.
  3. getScrollIndex() now returns the walked-back run-start index. No production caller reads it (only tests and the ScrollableList passthrough), so the semantic change is inert.
  4. Walk-back cost is negligible: worst-case 100k coincident offsets walk in 0.195 ms (node probe, 200k-item array).
  5. Degenerate all-zero totalHeight is sane (harness cell C): scrollHeight 0, scrollTop 0, no NaN in frame, scrollbar guarded by maxScroll > 0.

Not covered

  • Per-commit attribution. Metadata lists 13 commits; the depth-2 checkout exposes only merge/base/head (git rev-list --count HEAD^1..HEAD^2 returns the shallow-boundary artifact 1). The aggregate HEAD^1..HEAD diff is what was verified; per-commit behavior attribution is out of reach here.
  • Real-terminal E2E. The PR's description cites a real-component repro (expected 40 to be less than or equal to 8) whose harness is not committed; the committed unit tests and my harness reproduce the same transition at the ink level. A tmux TUI run of VP expand/collapse was not performed.
  • Legacy <Static> (non-VP) path beyond the pre-existing renderStatic test (green at head); the height cache does not feed that path's layout.
  • Repo-wide suite/lint/CI — the PR's own CI covers them; this round ran only the affected workspace's gates.

Methodology

Environment: CI node:22-bookworm container, merge-ref checkout (HEAD = caeb519830, HEAD^1 = base, HEAD^2 = verified head). All harnesses drive the compiled-from-source component via vitest + ink-testing-library with real yoga layout (mock-free w.r.t. the unit under test). Base arm: git worktree at HEAD^1 with package-level node_modules symlinked from the head tree (lockfile untouched by the PR; internal workspace imports resolve to each tree's own source via vitest aliases — realpath-asserted). Variants applied by exact-string mutation with occurrence-count checks (apply-variant.mjs), file restored and git status verified clean after each. Assertion unit = one executed vitest test (or one gate exit); expected-red control cells count as passes per the contract, and every mutant run's observed kill set matched its predicted set (any off-target red would have counted as fail). Raw logs in logs/, harness + mutator in this dir, PNG witnesses in evidence/.

Evidence images

01-ab-head-arm

01-ab-head-collapse-releases-height

02-ab-base-arm

02-ab-base-gap-remains

03-mutation-m1-reporter-guard

03-mutation-matrix-all-hunks-load-bearing

04-vacuity-v2-behavioral-failure

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 80019acab78681da0a4861fa4de9dc560f13a605 — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 80019acab78681da0a4861fa4de9dc560f13a605既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

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

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

@wenshao

wenshao commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Runtime verification (maintainer review)

I built a real end-to-end environment for this PR and executed all seven reviewer test-plan items, plus a genuine mixed-version run. All seven pass. Two non-blocking notes below.

Setup. Both arms compiled from source into runnable dist artifacts and driven as real processes — no mocks of the bridge, the child, or the health route:

AFTER PR head 80019acab7
BEFORE merge-base 20b9504276
Daemon node packages/cli/dist/index.js serve --workspace … --token …
Child real qwen --acp process
Model mock OpenAI: returns an agent tool-call with run_in_background: true, then parks the subagent's own completion so the background Agent stays running on demand
Wire capture QWEN_CLI_ENTRY stdio tee — every JSON-RPC frame in both directions, plus fault injection (drop snapshots / swallow the close reply / point at the old child)

verification report

Reviewer test plan

# Item Result
1 Background Agent outlives the main prompt activePrompts: 0 with activeWork: true for the Agent's whole life; clears 500 ms after the terminal notification settles
2 Cancel a background Agent in a detached Session ✅ hold hands off agentnotification with no gap on the wire (seq 5 → 6 → 8), then conditional close
3 Detach the last client while an Agent is active ✅ Session preserved; closed only after the child confirms unheld
4 Child refuses the conditional close {"closed":false,"holds":[{"category":"agent",…}]} on the wire; daemon adopts the set (grade flips partialfull)
5 Stall the child's close response ✅ no retry, no assumption, Session survives — ⚠️ see Note A on the prompt path
6 Child that does not acknowledge the capability ✅ ran a genuinely pre-PR qwen --acp build behind the new daemon: activeWorkReporting: "none", shallow health exactly {"status":"ok"}, cleanup identical to pre-PR
7 Daemon-wide aggregation + 503 aggregation_failed ✅ 2 workspaces / 3 Sessions, one workspace's Agent drives daemon-wide activeWork: true; a throwing getter still yields 503 {"reason":"aggregation_failed"} with the workspace attributed on stderr

The decisive A/B (item 3)

Same script, same mock, two builds:

step AFTER (PR head) BEFORE (merge-base)
prompt settled, Agent running sessions=1 activePrompts=0 activeWork=true reporting=full sessions=1 activePrompts=0
POST /detach → t+2s Session alive Session destroyed
t+10s Session alive destroyed
after the Agent + notification settle closed cleanly, sessions=0 (already gone, Agent killed with it)

raw evidence

Extra checks beyond the plan

  • Capability negotiation captured verbatim: daemon sends {"v":1,"intervalMs":15000}, child answers {"v":1,"intervalMs":15000,"categories":["agent","notification"]}, then publishes channel-wide full snapshots on qwen/notify/channel/active-work.
  • unknown grading is honest. With every snapshot dropped by the tee, an adopted hold set ages past 3 × 15 s and the daemon degrades to activeWorkReporting: "partial", activeWorkStaleMs: 0, activeWork: true — retained, and correctly flagged as not-vouched-for.
  • Mutation teeth on the hasUnfinalizedTasks() predicate. Driving the real compiled BackgroundTaskRegistry: inside the cancel window hasRunningTasks() is false, hasUnfinalizedTasks() is true, and listUnfinalizedBackgroundAgentIds() still returns the id. Patching the compiled dist to the running-only predicate makes it return [] — the choice is load-bearing, exactly as documented.
  • Unit suites at PR head, all green: acp-bridge 498, cli acp-integration 929, cli serve + Session 1230, core background-tasks 1232780 tests, no failures (I did not hit the Live Appshot flake).

Note A — a prompt inside the confirm window is accepted (202) and lost

rewindSession and spawnOrAttach throw synchronously, so REST answers 404 "The session is closing" — verified. sendPrompt instead returns a rejected promise, and the route's try/catch around it only catches synchronous throws, so it has already replied 202 {promptId}. Measured inside a stalled 10 s confirm window:

POST /session/:id/rewind -> 404  "The session is closing"
POST /session/:id/prompt -> 202  {promptId}
    daemon log: [SessionNotFoundError] ... The session is closing; retry after close completes
    events published for that promptId: 0

The caller gets no error and nothing on the event stream. This shape predates the PR (if (entry.closing) return Promise.reject(...)), so it is not a regression — but the PR widens the window it applies to from a fast synchronous teardown to a bounded 10 s round trip, and test-plan item 5 states the prompt is "refused rather than accepted and lost", which is not what a REST caller observes. Worth either softening that sentence or making sendPrompt's guard throw synchronously like its two siblings.

Note B — the conditional close does not carry the daemon's drain budget

confirmChildUnheld sends only {sessionId, onlyIfUnheld} (confirmed on the wire), so the child falls back to SESSION_DRAIN_TIMEOUT_MS = 30_000, while the real close sends drainTimeoutMs = initTimeoutMs * 0.8 = 8000 and the daemon itself only waits ACTIVE_WORK_CLOSE_TIMEOUT_MS = 10_000. A Session whose drain legitimately takes 10–30 s therefore times out the confirm and is retained for a later round. Self-correcting and in the safe direction, but it is an extra cycle the 8 s budget exists to avoid — consider forwarding drainTimeoutMs on the conditional call. (The missing parameter is runtime-confirmed; the consequence is a code read.)

Verdict

The mechanism does what the description says, the failure direction is genuinely the safe one (unreported or unconfirmed ⇒ retained, never destroyed), and the mixed-version path leaves old children behaving exactly as before. The two notes are documentation/polish, not correctness blockers.

LGTM — recommend merge. Note A's wording is worth a one-line fix before or after merge.

中文版

运行时验证(维护者评审)

我为本 PR 搭建了真实的端到端环境,执行了 Reviewer 测试计划的全部 7 条,另加一次真实的跨版本(新 daemon + 旧子进程)验证。7 条全部通过,另有 2 条不阻塞的说明。

环境。 两侧均从源码编译成可运行的 dist,以真实进程驱动 —— bridge、子进程、health 路由都没有被 mock:

AFTER PR head 80019acab7
BEFORE merge-base 20b9504276
Daemon node packages/cli/dist/index.js serve --workspace … --token …
子进程 真实 qwen --acp 进程
模型 mock OpenAI:先返回 run_in_background: trueagent tool-call,再挂起子 agent 自己的补全请求,从而按需让后台 Agent 保持 running
抓包 QWEN_CLI_ENTRY stdio tee —— 双向抓取每一条 JSON-RPC 帧,并支持故障注入(丢弃快照 / 吞掉 close 应答 / 指向旧版子进程)

Reviewer 测试计划

# 条目 结果
1 后台 Agent 存活时间超过主 Prompt ✅ 整个 Agent 生命周期内 activePrompts: 0activeWork: true;终态通知 settle 后 500 ms 归零
2 在 detached session 中 cancel 后台 Agent ✅ hold 在线上从 agentnotification 无空档交接(seq 5 → 6 → 8),随后条件关闭
3 Agent 运行中断开最后一个客户端 ✅ Session 被保留;只有在子进程确认无 hold 后才关闭
4 子进程拒绝条件关闭 ✅ 线上出现 {"closed":false,"holds":[{"category":"agent",…}]};daemon 采纳该 hold 集(分级由 partial 翻为 full
5 子进程 close 应答卡住 ✅ 不重试、不假设、Session 存活 —— ⚠️ prompt 路径见说明 A
6 未确认能力的子进程 ✅ 用真正的 PR 前 qwen --acp 构建挂在新 daemon 后面:activeWorkReporting: "none",浅层 health 严格为 {"status":"ok"},清理行为与 PR 前完全一致
7 daemon 级聚合 + 503 aggregation_failed ✅ 2 个 workspace / 3 个 Session,单个 workspace 的 Agent 即可让全局 activeWork: true;让 getter 抛异常仍返回 503 {"reason":"aggregation_failed"},并在 stderr 标注是哪个 workspace

决定性 A/B(第 3 条)

同一脚本、同一 mock、两个构建:

步骤 AFTER(PR head) BEFORE(merge-base)
prompt settle,Agent running sessions=1 activePrompts=0 activeWork=true reporting=full sessions=1 activePrompts=0
POST /detach → t+2s Session 存活 Session 已销毁
t+10s Session 存活 已销毁
Agent 与通知 settle 之后 干净关闭,sessions=0 (早已消失,Agent 一并被杀)

计划之外的补充验证

  • 能力协商逐字抓取:daemon 发 {"v":1,"intervalMs":15000},子进程回 {"v":1,"intervalMs":15000,"categories":["agent","notification"]},随后在 qwen/notify/channel/active-work 上发布 channel 级全量快照。
  • unknown 分级是诚实的。 用 tee 丢弃全部快照后,被采纳的 hold 集老化超过 3 × 15 s,daemon 降级为 activeWorkReporting: "partial"activeWorkStaleMs: 0activeWork: true —— 保留 Session,并如实标注"这个值没人担保"。
  • hasUnfinalizedTasks() 判据做了变异测试。 直接驱动真实编译后的 BackgroundTaskRegistry:在 cancel 窗口内 hasRunningTasks()falsehasUnfinalizedTasks()true,而 listUnfinalizedBackgroundAgentIds() 仍返回该 id。把编译产物改成 running-only 判据后返回 [] —— 判据选择确实承重,与文档描述一致。
  • PR head 单测全绿: acp-bridge 498、cli acp-integration 929、cli serve + Session 1230、core background-tasks 123,合计 2780 条,无失败(我没有遇到 Live Appshot 那条 flake)。

说明 A —— confirm 窗口内的 prompt 会被接受(202)并丢失

rewindSessionspawnOrAttach同步 throw,所以 REST 返回 404 "The session is closing" —— 已验证。而 sendPrompt返回一个 rejected promise,路由外层的 try/catch 只能捕获同步抛出,此时它已经回过 202 {promptId} 了。在被卡住的 10 秒 confirm 窗口内实测:

POST /session/:id/rewind -> 404  "The session is closing"
POST /session/:id/prompt -> 202  {promptId}
    daemon 日志: [SessionNotFoundError] ... The session is closing; retry after close completes
    该 promptId 产生的事件数: 0

调用方既拿不到错误,事件流上也什么都没有。这个形状早于本 PRif (entry.closing) return Promise.reject(...)),因此不是回归;但本 PR 把它适用的窗口从"一次快速的同步拆除"扩大到"有界的 10 秒往返",而测试计划第 5 条写的是 prompt 会"被拒绝而不是被接受后丢失",这与 REST 调用方观察到的不符。建议要么调整这句措辞,要么让 sendPrompt 的守卫像另外两处一样同步抛出。

说明 B —— 条件关闭没有携带 daemon 的 drain 预算

confirmChildUnheld 只发送 {sessionId, onlyIfUnheld}(已在线上确认),因此子进程回落到 SESSION_DRAIN_TIMEOUT_MS = 30_000,而真正的 close 发送的是 drainTimeoutMs = initTimeoutMs * 0.8 = 8000,daemon 自身也只等 ACTIVE_WORK_CLOSE_TIMEOUT_MS = 10_000。于是一个 drain 合理耗时 10–30 秒的 Session 会让 confirm 超时,被保留到下一轮。方向是安全的且能自愈,但多出了一个 8 秒预算本想避免的循环 —— 建议在条件调用上一并转发 drainTimeoutMs。(缺少该参数是运行时确认的;后果部分是代码阅读推论。)

结论

机制与描述相符,失败方向确实落在安全侧(未上报或未确认 ⇒ 保留,绝不销毁),跨版本路径也让旧子进程保持原样。两条说明属于文档与打磨,不构成正确性阻塞。

LGTM —— 建议合并。 说明 A 的措辞值得在合并前后顺手改一行。

wenshao
wenshao previously approved these changes Aug 7, 2026

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

中文说明

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

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

Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/cli/src/acp-integration/active-work-reporter.ts
Comment thread packages/cli/src/acp-integration/active-work-reporter.ts
Comment thread docs/developers/qwen-serve-protocol.md
Comment thread docs/design/2026-08-06-active-work-health.md
Comment thread packages/cli/src/acp-integration/acpAgent.ts
Review found three ways the conditional close can still destroy a live
Session. All three share a cause: the round trip turned a synchronous
guard-then-teardown into an awaited span, and three things that were
previously impossible to observe mid-teardown now are.

**A restore in flight looks exactly like an abandoned Session.**
`session/load` registers the entry before awaiting `artifacts.restore()`
and `seedSessionUpdates()`, and registers its first client only after —
so for that whole window there are no clients, no subscribers, nothing
held, and the child answers the conditional close truthfully. The
snapshot trigger this PR added fires inside it. Excluded in
`entryIsAutoCloseCandidate` rather than at the snapshot trigger, so the
reaper's TTL elapsing inside a slow restore is covered too.
`pendingRestoreIds` already existed but was read only by
`hasNoChannelWork`, never by the close funnel.

**Teardown re-resolved the target by id without re-checking identity.**
`closeSessionImpl` does a fresh `byId.get`, and the id can be
re-registered to a different entry during the round trip: an explicit
kill removes this one (kill ignores the in-flight flag by design, keeping
its force semantics) and a `session/load` for the same persisted id
registers a fresh one. The stale continuation then tore down the newly
restored Session under its just-attached client. One identity re-check
after the await.

**The restore path was not upgraded to the new admission predicate.**
`sendPrompt`, `rewindSession`, and single-scope attach check
`isClosingOrAuthorizingClose`; `restoreSession` still checked bare
`closing` at both its guards, so a client could attach inside the window
and lose the session under it. That directly contradicted the
`closeIfChildUnheld` comment claiming every admission path checks the
flag. Its `racedEntry` branch had no closing guard at all — a narrower
pre-existing hole, same defect, same predicate.

Regression test covers the restore-path admission refusal. The other two
need a mid-restore snapshot and a kill-then-reload interleave that the
mocked-channel harness cannot stage honestly; both are pinned by reading
the code paths, which is weaker and worth saying.
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review round addressed — 3 Critical fixed in 12adf869, 9 Suggestions deferred

All three [Critical] findings were real. I verified each against the code rather than taking the tag, and one had an inaccurate provenance claim that I've corrected in-thread while accepting the underlying bug.

Fixed

Finding Verified how Fix
Snapshot auto-close can tear down a session/load mid-restore Entry is registered before await artifacts.restore() / seedSessionUpdates(), first client only after; pendingRestoreIds was read solely by hasNoChannelWork, never by the close funnel Excluded in entryIsAutoCloseCandidatenot at the snapshot trigger as the inline suggestion had it, because the reaper's TTL can elapse inside a slow restore too
Teardown re-resolves by id after the await without an identity re-check closeSessionImpl does a fresh byId.get; kill deliberately ignores the in-flight flag, so the id can be re-registered mid-round-trip One identity re-check after confirmChildUnheld
restoreSession guards on bare closing, not the new predicate Three admission paths upgraded, this one missed — contradicting this PR's own "every admission path checks this flag" comment Both guards upgraded, plus the racedEntry branch which had no closing guard at all

All three share one cause: turning a synchronous guard-then-teardown into an awaited span made three previously unobservable mid-teardown states reachable. That is the same root cause as the guard rework earlier in this PR, which is the honest reading — the confirm window is genuinely the risky part of this design, and this is the third round of finding things inside it.

Verification: acp-bridge 1111/1111, active-work subset 18/18 including a new regression for the restore-path admission refusal. The other two fixes are pinned only by reading the code paths — a mid-restore snapshot and a kill-then-reload interleave are not something the mocked-channel harness can stage honestly, and I'd rather say so than imply test coverage I don't have.

Deferred — 9 Suggestions, replied and resolved individually

Not disputed, and none silently dropped. This is round 6+ of automated review on a PR that already carries a maintainer approval, so autonomous changes are held to critical-level findings rather than accumulating commits on an approved diff.

Five are flagged to the author as worth promoting, because they are the same class of problem this PR has already had to correct twice — a claim broader than the implementation:

  • The refusal-response hold set is adopted unbounded, while parseActiveWorkSnapshot caps the identical payload. Capping only one of two ingestion paths is an inconsistency introduced here.
  • A single huge-but-valid seq permanently latches the channel high-water mark, costing the self-healing property the design doc advertises (fails closed, so no destruction).
  • The reporter's eager constructor publish appears to be always discarded, which would make the comment justifying it false.
  • Design doc drift: the close-response success shape, and the child proposes, the daemon clamps is inverted.
  • Protocol doc drift: the activeWork enumeration omits the fail-closed unknown ⇒ active behavior a restart controller depends on.

The remaining four are bounded churn or scope (enqueueBackgroundNotification, setSessionApprovalMode / setSessionModel, a dead readonly, and the test-coverage gap — partly exercised by the maintainer's live run, though not by a committed test, so the mutation described would still pass CI).

Threads: 12/12 replied and resolved. 0 unresolved.

@wenshao

wenshao commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Runtime re-verification at 12adf869 — the three critical fixes

My earlier report tested 80019acab7. Three [Critical] fixes landed since, and the author states two of them are "pinned only by reading the code paths" — a mid-restore snapshot and a kill-then-reload interleave. I rebuilt the environment at the new head and went after exactly those.

Setup. Three arms, all compiled from source into runnable dist and driven as real processes — no mocked bridge, no mocked child, no mocked health route:

AFTER PR head 12adf869
BEFORE (fix-level) previous head 80019acab7 — isolates the three fixes
BEFORE (PR-level) merge-base 20b9504276
Daemon node packages/cli/dist/index.js serve --workspace … --token …, fresh daemon + fresh workspace per scenario
Child real qwen --acp process
Fault injection QWEN_CLI_ENTRY stdio tee: logs every JSON-RPC frame both ways, and can delay the response to qwen/control/session/close {onlyIfUnheld:true}, delay any close, drop snapshots, or replay the child's own snapshot at a chosen instant
Model scripted OpenAI-compatible mock that launches a background Agent and then parks the sub-agent's completion on demand

The three fixes

# Fix Verdict
1 restoreSession guards on the new predicate, not bare closing confirmed live — decisive A/B below
2 identity re-check after confirmChildUnheld ⚠️ not stageable over REST — see below
3 pendingRestoreIds excluded from entryIsAutoCloseCandidate ⚠️ attempted hard, inconclusive

Fix 1 — restore admission (confirmed)

Same driver, same stalled child (conditional-close response held 7 s), two daemons. POST /session/:id/load issued 1.2 s into the confirm window:

On 80019ac the caller is told 200 {"attached":true, clientId:…} and the teardown it raced then destroys the Session under it — no error ever reaches the client. On 12adf869 it is refused up front with 404 "The session is closing; retry after close completes". This is a real defect, really fixed.

The full admission matrix inside an 8 s window at the new head is in the screenshot: load 404, rewind 404, attach-by-id 409, prompt 202 then lost, DELETE 409.

Fix 2 — identity re-check (could not be staged, and that is informative)

To reach it I need the id removed and re-registered during the confirm round trip. Over REST that is not reachable at this head:

  • there is no kill route (killSession is only called from sub-session/archive/rollback paths), and
  • DELETE /session/:id inside the window returns 409 session_archiving, Retry-After: 5 — because POST /session/:id/detach runs under withOwnerMutableSession, which holds a shared archive lock for the whole handler, and the handler now awaits the conditional close.

So the lock that blocks my repro is also what narrows this race's real-world exposure — the re-registration has to come from a non-REST path. The guard costs one map lookup and fails safe; I'd keep it, but it stays code-read-only.

Fix 3 — mid-restore exclusion (inconclusive, and I tried hard)

I could not reach the state it prevents:

  • The window is ~1 ms. Polling /health?deep=1 at ~5 samples/ms across a 501 ms session/load of a 1200-record transcript, exactly 1 of 2453 samples saw the entry registered while the load was still in flight.
  • Snapshot path: replaying the child's own qwen/notify/channel/active-work snapshot 700 times at 1 ms resolution across the whole restore did not tear the Session down on 80019ac.
  • Reaper path: with --session-reap-interval-ms 1 --session-idle-timeout-ms 1, both builds behave identically — the conditional close for the restoring id goes out 5–6 ms after the child's load response on 80019ac and on 12adf869. I cannot separate "decided inside the guarded window" from "decided in the unguarded tail between the restore promise settling and the HTTP response being written", so the test does not discriminate.

Not evidence the fix is wrong — evidence that the live path is too narrow to pin. If you want it pinned, a unit test with a controllable await inside the restore is the only honest way.

What still holds at the new head

  • Headline claim, re-proven against the merge base. Main prompt settled with a background Agent still running: activePrompts: 0, activeWork: true, reporting: "full". Detach the last client → merge-base destroys the Session (404, channel dead, Agent killed with it); PR head retains it (200). Agent + terminal notification settle → clean close, sessions: 0, activeWork: false.
  • Negotiation and holds captured verbatim. Daemon sends initialize._meta {"v":1,"intervalMs":15000}; child answers {"v":1,"intervalMs":15000,"categories":["agent","notification"]}; holds appear as {"category":"agent","id":"general-purpose-call_3"} while activePrompts is already 0.
  • Fail-closed is honest. A Session on a negotiated channel that has not been named by a snapshot yet reads activeWork: true, reporting: "partial" until its first report — the documented "unknown ⇒ retained" state, observed live.
  • Unit suites at 12adf869: acp-bridge 499/499, cli acp-integration 929/929, cli serve 1218/1218, core background-tasks 123/1232769 tests, no failures. CI on this head is green on every job.

Notes (non-blocking)

A. A prompt inside the confirm window is still accepted (202) and lost. Unchanged from my last report and re-measured here: POST /session/:id/prompt202 {promptId}, and after the window GET /session/:id/pending-prompts → 404. load and rewind both throw synchronously and answer 404; sendPrompt returns a rejected promise the route's try/catch cannot see. Pre-existing shape, but the PR widens the window it applies to, and test-plan item 5 claims the prompt is "refused rather than accepted and lost", which is not what a REST caller observes.

B. The conditional close still omits the drain budget. On the wire: {"sessionId":…,"onlyIfUnheld":true} versus the real close's {"sessionId":…,"drainTimeoutMs":8000}. The child falls back to SESSION_DRAIN_TIMEOUT_MS = 30_000 while the daemon waits only 10 s. Self-correcting, safe direction, one wasted cycle.

C. Detach costs one extra bounded round trip. I checked whether this is new before reporting it: with every session/close response stalled 8 s, POST /session/:id/detach takes 8019 ms on the merge base, 16017 ms on 80019ac, 16016 ms on 12adf869. So blocking detach is pre-existing; the PR adds one bounded confirm in front of it (worst case 10 s confirm + 8 s drain). With a healthy child it is 15 ms. Worth knowing because the route holds its shared archive lock for that whole span, so DELETE/archive on that Session are refused with 409 meanwhile — "explicit close keeps force semantics" is true of the bridge, but not of what a REST caller sees.

Verdict

The one fix that is observable from outside the daemon is genuinely fixed, and I reproduced the bug it fixes on the previous head. The other two are in the safe direction and cheap; I could not stage either, which matches the author's own statement rather than contradicting it. Everything I verified last round still holds at 12adf869, and the failure direction remains "unreported or unconfirmed ⇒ retained, never destroyed".

LGTM — recommend merge, my previous approval stands at this head. Note A is worth one line of code or one line of prose before or after merge.

中文版

12adf869 上的运行时复验 —— 针对三个 Critical 修复

我上一份报告测的是 80019acab7。此后合入了三个 [Critical] 修复,作者明确说明其中两个只靠读代码确认,没有测试覆盖(restore 期间的快照、kill 后重新加载的交错)。我在新 head 上重建了环境,专门去打这两个点。

环境。 三个 arm,全部从源码编译成可运行的 dist,以真实进程驱动 —— bridge、子进程、health 路由都没有 mock:

AFTER PR head 12adf869
BEFORE(修复级) 上一个 head 80019acab7 —— 用于隔离这三个修复
BEFORE(PR 级) merge-base 20b9504276
Daemon node packages/cli/dist/index.js serve --workspace … --token …,每个场景都用全新 daemon + 全新 workspace
子进程 真实 qwen --acp 进程
故障注入 QWEN_CLI_ENTRY stdio tee:双向记录每一条 JSON-RPC 帧,并可延迟 qwen/control/session/close {onlyIfUnheld:true} 的应答、延迟任意 close、丢弃快照,或在指定时刻重放子进程自己的快照
模型 脚本化的 OpenAI 兼容 mock:先拉起后台 Agent,再按需挂起子 agent 的补全

三个修复

# 修复 结论
1 restoreSession 改用新判据而非裸 closing 实测确认
2 confirmChildUnheld 之后补身份重校验 ⚠️ REST 层无法构造
3 entryIsAutoCloseCandidate 排除 pendingRestoreIds ⚠️ 尽力尝试,无法判定

修复 1(已确认)。 同一脚本、同一被卡住的子进程(条件关闭应答延迟 7 秒),两个 daemon;在 confirm 窗口内 1.2 秒处发 POST /session/:id/load80019ac 返回 200 {"attached":true, clientId:…},随后它所竞争的拆除把这个 Session 在客户端脚下销毁 —— 调用方拿不到任何错误12adf869 直接 404 "The session is closing; retry after close completes"。真实缺陷,真实修好。窗口内完整的准入矩阵见截图:load 404、rewind 404、按 id attach 409、prompt 202 然后丢失DELETE 409。

修复 2(无法构造,但这件事本身有信息量)。 要触发它,必须在 confirm 往返期间把同一个 id 移除重新注册。当前 head 上 REST 做不到:没有 kill 路由(killSession 只在子会话/归档/回滚路径里被调用);而窗口内的 DELETE /session/:id 会返回 409 session_archivingRetry-After: 5—— 因为 POST /session/:id/detach 跑在 withOwnerMutableSession 里,整个 handler 期间持有 archive 的共享锁,而该 handler 现在要 await 条件关闭。也就是说,挡住我复现的那把锁,同时也压缩了这个竞态的现实暴露面 —— 重新注册只能来自非 REST 路径。这个守卫只值一次 map 查找且失败方向安全,我建议保留,但它仍然只是"读代码确认"。

修复 3(无法判定,且我确实尽力了)。

  • 窗口只有约 1 毫秒:以约 5 次/毫秒的频率轮询 /health?deep=1,覆盖一次 1200 条记录、耗时 501 ms 的 session/load2453 个采样里只有 1 个看到"条目已注册但 load 仍在进行"。
  • 快照路径:把子进程自己的 qwen/notify/channel/active-work 快照以 1 ms 粒度重放 700 次贯穿整个 restore,80019ac 上 Session 依然没有被拆除。
  • reaper 路径:用 --session-reap-interval-ms 1 --session-idle-timeout-ms 1,两个构建表现完全一致 —— 针对正在 restore 的 id 的条件关闭,都在子进程 load 应答之后 5–6 ms 发出。我无法区分"在被守卫的窗口内做的决定"和"在 restore promise settle 之后、HTTP 响应写出之前那段无守卫尾巴里做的决定",因此该测试不具备区分力。

这不是说修复错了,而是说这条活路径太窄、钉不住。若要钉死,只能写一个能控制 restore 内部 await 时机的单测。

新 head 上依然成立的部分。 主 Prompt 结束、后台 Agent 仍在跑时:activePrompts: 0activeWork: truereporting: "full";断开最后一个客户端后,merge-base 销毁 Session(404,通道死亡,Agent 一并被杀),PR head 保留(200);Agent 与终态通知 settle 后干净关闭,sessions: 0activeWork: false。能力协商逐字抓取:daemon 发 {"v":1,"intervalMs":15000},子进程回 {"v":1,"intervalMs":15000,"categories":["agent","notification"]},hold 形如 {"category":"agent","id":"general-purpose-call_3"}。尚未被快照点名的 Session 读作 activeWork: true, reporting: "partial",即文档中的"unknown ⇒ 保留",实测如此。单测:acp-bridge 499/499、cli acp-integration 929/929、cli serve 1218/1218、core background-tasks 123/123,合计 2769 条全绿;该 head 的 CI 全部 job 通过。

说明(均不阻塞)。

  • A. confirm 窗口内的 prompt 仍然被 202 接受然后丢失。 与上次一致并重新实测:POST /session/:id/prompt202 {promptId},窗口结束后 GET /session/:id/pending-prompts → 404。loadrewind 都是同步抛出并返回 404;sendPrompt 返回的是 rejected promise,路由的 try/catch 看不见。形状早于本 PR,但本 PR 扩大了其适用窗口,而测试计划第 5 条写的是 prompt 会"被拒绝而非被接受后丢失",与 REST 调用方观察到的不符。
  • B. 条件关闭仍未携带 drain 预算。 线上为 {"sessionId":…,"onlyIfUnheld":true},而真正的 close 是 {"sessionId":…,"drainTimeoutMs":8000}。子进程回落到 SESSION_DRAIN_TIMEOUT_MS = 30_000,daemon 却只等 10 秒。可自愈、方向安全,只是多一个循环。
  • C. detach 多出一次有界往返。 我在报告前先做了对照:把所有 session/close 应答都延迟 8 秒,POST /session/:id/detach 在 merge-base 上耗时 8019 ms80019ac16017 ms12adf86916016 ms。所以"detach 会阻塞"并非新增,本 PR 只是在它前面加了一次有界 confirm(最坏 10 秒 confirm + 8 秒 drain);子进程健康时是 15 ms。之所以值得知道,是因为该路由在整段时间里持有 archive 共享锁,期间对该 Session 的 DELETE/归档会被 409 拒绝 —— "显式 close 保持强制语义"对 bridge 成立,但对 REST 调用方看到的结果并不成立。

结论

唯一能从 daemon 外部观测到的那个修复确实修好了,我也在上一个 head 上复现了它所修的缺陷。另外两个方向安全、代价极小;我两个都没能构造出来,这与作者自己的说法一致,而非相反。上一轮验证过的内容在 12adf869 上全部依然成立,失败方向仍然落在安全侧(未上报或未确认 ⇒ 保留,绝不销毁)。

LGTM —— 建议合并,我此前的 approve 在本 head 上继续有效。说明 A 值得在合并前后顺手改一行代码或一行措辞。

@wenshao

wenshao commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

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

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

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

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

Verification report

<!-- qwen-triage:verify -->
<!-- qwen-triage:verify-substantive -->

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

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: 3473 passed · 0 failed · 3473 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)
  • 结论merge-ready。3473/3473 脚本断言通过,0 个意外失败。
  • A/B 结论:中心声明成立且由本 PR 承载。head 上 41/41:协商能力、未知状态 fail-closed、stale 快照停止作保、"先问后拆"(拒绝则保留并采纳 hold、无应答不重试不假设、已知 hold 直接阻断拆除);base 上 15/15 证实对照行为:不协商、无 activeWork 字段、最后一个客户端 detach 时不询问子进程即销毁持有/过期/未知工作的 Session。
  • 突变矩阵:三个新 guard 全部 load-bearing(M1 拒绝路径 3 红、M2 准入 guard 1 红、M3 reporter 收集 guard 5/9 红,与 commit 自述完全一致);正对照(PR 前 guard)1 红证明套件有判别力;反向突变 M5 两侧全绿 → 发现一个未钉住的时序轴(见 Findings,非阻塞)。
  • 未覆盖:逐 commit 归因(depth-2);真实模型驱动的 E2E(无凭证,仅复现 wire 形状);macOS/Windows;rewind 准入端与 prompt 响应前 flush 的测试钉住(均为 Suggestion)。
  • 说明previous-report.md 描述的是另一个 PR(8570,VirtualizedList 零高度修复),与本 PR 无关,故本轮无可继承的发现,按首轮处理。
Verification report

PR 8588 — feat(serve): expose active work state

Verdict: merge-ready — 3473/3473 scripted assertions passed, 0 unexpected failures. Verified head: 12adf869f356358aedb796e912b0a81622c8dcb2 (base 4ec0371e616decbe723cec250e19943b226d31e1).

Note on previous-report.md: the previous round's report describes PR 8570 (VirtualizedList zero-height items) — its title, verified head OID (cab04c9c…), and every finding belong to a different change. There are therefore no prior findings about this PR to carry forward, and no status table is fabricated; this round is effectively a first round for PR 8588.

Central claim + A/B

Central claim: GET /health?deep=1 gains activeWork/activeWorkReporting/activeWorkStaleMs that correctly reflect non-prompt work (background Agents, unsettled terminal notifications) via channel-wide child snapshots, and automatic cleanup never destroys a Session on cached/stale/unknown state — it asks the child under its close gate and retains on any non-confirmation.

Harness: ab-active-work.mjs (archived here), run identically on both arms against the compiled dist of each tree, through the project's in-memory NDJSON channel seam with real @agentclientprotocol/sdk connections on both sides — the fake is only the peer (FakeAgent), never the unit under test. Wire oracle = the exact extMethod/extNotification traffic the fake child observed; health oracle = bridge.activeWork/activeWorkCoverage (the values the /health?deep=1 route reads). Base arm: scratch worktree at HEAD^1 with core+acp-bridge rebuilt there; readlink -f asserted @qwen-code/qwen-code-core and @qwen-code/acp-bridge resolve into the base tree (PR leaves every package.json/lockfile untouched, so reusing the head install's nested node_modules is a clean control).

cell scenario oracle head base (control)
C0 initialize handshake _meta proposal ✅ proposes {v:1, intervalMs:15000} ✅ does not negotiate
C1 negotiated, never reported health activeWork=true (fail-closed), coverage {1,0,1}partial ✅ fields absent
C2 snapshot with agent hold health activeWork=true, covered=1 ✅ unaffected
C3 empty-hold snapshot, client attached retention activeWork=false, session kept ✅ kept
C4 detach while child reports held work wire + retention ✅ retained, zero close calls destroyed, only force-close (no onlyIfUnheld)
C5 detach on empty snapshot; child REFUSES with late hold wire + retention ✅ asked once (onlyIfUnheld), retained, hold adopted ✅ destroyed, never asked
C6 held snapshot aged past 3 intervals, then detach coverage + wire covered=0, still busy, asked once, retained ✅ destroyed
C7 never-reported; close request HANGS wire + retention ✅ asked once, no retry, retained after 10 s bound ✅ destroyed, never asked
C8 cooperative child confirms unheld wire + retention ✅ 1 conditional call, then teardown ✅ destroyed without confirmation
C9 gradeActiveWorkCoverage (6) + clampActiveWorkIntervalMs (12) probes pure ✅ 18/18 n/a (functions absent)

Witnesses: 01-ab-head-ask-before-close-retains.png (head 41/41), 02-ab-base-force-close-destroys-held-work.png (base 15/15 — the base arm asserts base's destructive behavior, so its green is the control's red). The head arm's C7 stderr line (close-if-unheld … did not resolve … leaving it in place for the next snapshot to settle) confirms the suppression of the destroy is still observable — the reason survives in the log.

Secondary claims

  1. Every automatic teardown asks before destroying, and the guards are load-bearing. Mutation matrix (03-mutation-matrix-guards-load-bearing.png, re-run live; each mutant reverted, tree clean after):
mutant change observed kill set verdict
M1 confirmChildUnheld: refusal treated as closed 3 red — the three refusal-path retention tests, nothing else load-bearing
M2 sendPrompt admission guard := entry.closing 1 red — 'refuses new prompts while a conditional close is in flight'; the attach-path twin (unmutated guard) stays green load-bearing, precise attribution
M3 reporter collection guard := rethrow 5/9 red — exactly the collection-failure block, matching commit fb68eeb5's own claim; failures are behavioral (flush() rejects with the contained error) load-bearing, tests non-vacuous
M4 positive control (pre-PR axis): external-tool-guard ack := false 1 red — the pre-existing Guard-channel test suite has teeth off the PR's axis
M5 reverse: remove pre-response flush() from prompt path 386/386 green both sides nothing pins the ordering (Finding 1)
  1. Health aggregation composes correctly daemon-wide. Serve suites at head: server/multi-workspace/run-qwen-serve 1218/1218, including the empty-workspace-cannot-vouch case, the OR-across-workspaces case, the throwing-getter → 503 aggregation_failed case, and the shallow {status:"ok"} shape pin. Pure-function probes (C9) confirm the grade/clamp boundaries.

  2. Child-side holds are derived, not ledgered, and cover the cancel window. Session holds suite 572/572 (Session + reporter + review-lease + worktree): 'derives agent holds from the registry, covering the cancel window' pins the hasUnfinalizedTasks() predicate choice (Reviewer Test Plan item 2); core background-tasks 123/123 pins listUnfinalizedBackgroundAgentIds/clearStatusChangeCallback. acpAgent 386/386 pins the negotiation echo (1 ms proposal clamped to 5000) and the per-Session change-callback wiring.

Findings (non-blocking)

  1. The pre-response snapshot flush is not pinned by any test. Removing await this.activeWorkReporter?.flush() from the prompt settle path leaves acpAgent 386/386 green (M5) — the suite cannot tell head from head-minus-flush. The behavior in source is correct, and the safety net still holds without it (a daemon that briefly sees an idle detached Session still asks the child, which refuses under its close gate — C5), so this narrows a window rather than removing a guarantee. The fixture that would pin it: a fake connection that records the order of extNotification(ACTIVE_WORK_NOTIFICATION_METHOD) and the prompt response on the same stream and asserts the snapshot lands first.
  2. The rewind admission end of isClosingOrAuthorizingClose is unpinned. rewindSession carries the same guard as prompt and attach (bridge.ts ~8702), and the prompt and attach ends are pinned by the PR's tests, but no test refuses a rewind during an in-flight conditional close. Same class as Finding 1: the guard exists and is correct in source; only the pin is missing.

Not covered

  • Per-commit attribution. Metadata lists 9 commits; the depth-2 checkout exposes only merge/base/head, so the aggregate HEAD^1..HEAD diff is what was verified.
  • Model-driven E2E of Test Plan items 1–2 (a prompt that really spawns a background Agent; a real cancel during the finalize window): no credentials in this container. This round reproduces the wire shapes those flows produce (child reporting holds, refusing conditional closes), not the model-side triggers. Items 3–7 were exercised live or via the gates as mapped in Secondary claims.
  • macOS/Windows — Linux container only (matches the PR's own tested-on table).
  • Repo-wide suite/lint — the PR's own CI covers them; this round ran the affected workspaces' gates only: acp-bridge 1112/1112, cli Session+reporter 572/572, cli acpAgent 386/386, cli serve 1218/1218, core background-tasks 123/123, repo typecheck clean (0 errors).

Methodology

Environment: CI node:22-bookworm container, merge-ref checkout (HEAD = fefd25c33b, HEAD^1 = base, HEAD^2 = verified head). The A/B harness drives each tree's compiled dist/ through the project's in-memory NDJSON seam (real ACP SDK connections both ends; only the peer is fake), asserting health values, session retention, and the exact close-related wire traffic per arm; base-arm assertions encode base's expected destructive behavior, so every green there is a control red. The base control rebuilds core+acp-bridge at HEAD^1 with internal workspace links realpath-asserted into the base tree (dependency tree untouched by the PR). Mutations applied by exact-string replacement with occurrence-count checks, suites re-run per mutant, tree restored and git status verified clean. Assertion unit = one executed vitest test or one scripted harness check; expected-red mutant cells and expected-base cells count as passes per the contract; every observed kill set matched its prediction. Raw logs in logs/, harnesses in this dir, PNG witnesses in evidence/.

Evidence images

01-ab-head-ask-before-close-retains

02-ab-base-force-close-destroys-held-work

03-mutation-matrix-guards-load-bearing

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 12adf869f356358aedb796e912b0a81622c8dcb2 — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 12adf869f356358aedb796e912b0a81622c8dcb2既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

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

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

@wenshao

wenshao commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /review

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor
_Qwen Code review request accepted. Review is queued in [workflow run](https://github.com/QwenLM/qwen-code/actions/runs/31243191061)._

@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. The three round-1 Criticals are fixed at this head — verified by reading the current code, not just the author notes: snapshot auto-close now excludes pendingRestoreIds, the stale continuation is re-checked by identity immediately before closeSessionImpl, and all three admission paths (including the previously unguarded raced-entry branch) use isClosingOrAuthorizingClose.

Counter pairing on the reporting side holds on all paths (inc-before-try / dec-in-finally), holds are derived per report from the real work owners so nothing can outlive the work it names, unknown or stale state fails closed to busy with a downgraded reporting grade, and the new health fields are purely additive. CI is green on this head.

The remaining items are the ones already deferred with maintainer approval and tracked in the automated review thread — non-blocking: uncapped refusal-hold adoption, the seq high-water latch, the eager constructor publish that is deterministically discarded, the three admission paths still missing the close guard, and the doc drift. Nothing here blocks merge.

@doudouOUC
doudouOUC dismissed qwen-code-ci-bot’s stale review August 8, 2026 06:34

Already have 2 appoved,3ks

@doudouOUC
doudouOUC added this pull request to the merge queue Aug 8, 2026
Merged via the queue into QwenLM:main with commit 59b750f Aug 8, 2026
161 of 171 checks passed
doudouOUC added a commit to doudouOUC/qwen-code that referenced this pull request Aug 8, 2026
Merging main's active-work close protocol (QwenLM#8588) into this PR's abandoned
restore bound produced a deadlock that neither side has on its own, and the
conflict resolution was committed without running tests.

`maybeCloseIdleSession` now routes through `confirmChildUnheld`, which asks
the child whether it still holds work before closing a session nobody is
attached to. That is right in general and wrong for a channel this PR has
already condemned. `restoreSettlementOverdue` and quarantine exist precisely
because the child stopped being answerable, and their whole premise is that
visible work drains so the channel can be reaped — closing the transport is
the only thing that can release a restore we cannot cancel. Making that
drain depend on a round trip to the wedged child inverts it: a child stuck
in a non-cancellable restore is exactly the one that cannot reply inside
`ACTIVE_WORK_CLOSE_TIMEOUT_MS`, so the sessions never close, the channel
never drains, the reap never fires, and the bound never takes effect.

A channel condemned by the restore lifecycle now skips the round trip and
proceeds to local teardown. Nothing is attached to the session by then —
`maybeCloseIdleSession` gates on that — and the sibling-safety invariant is
untouched: this closes sessions whose clients have already left, it does not
force-kill a channel that still has live ones.

The regression test drives an overdue channel whose child never answers the
close-if-unheld probe and asserts the detach still reaps it. Reverting the
guard reproduces the deadlock as a test timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.8.

pull Bot pushed a commit to mcx/qwen-code that referenced this pull request Aug 9, 2026
…#8691)

* fix(serve): make session restore timeouts safe

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

* fix(cli): restore missing core mock exports in the ACP worktree suite

The restore-tracing change added `extractDaemonTraceContext` and
`withDaemonSpan` to `acpAgent.ts`, but `acpAgent.worktree.test.ts`
replaces `@qwen-code/qwen-code-core` with a full mock factory that never
listed them. `loadSession` then failed on an undefined export, taking all
three cases down and producing teardown rejections from the half-built
agent. The sibling suite was updated; this one was missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(serve): bound and disambiguate the abandoned restore lifecycle

Four follow-ups from review of the restore timeout work.

A startup budget may now raise the restore budget but never lower it.
Taking an explicitly configured `initializeTimeoutMs` as the restore
fallback meant a deployment that tightened its child-initialize check
still inherited a sub-default restore deadline — exactly the failure this
change exists to remove. An explicit `sessionRestoreTimeoutMs` still wins
outright, including below the default, for deployments that want restore
to fail fast. Validation now names the field actually at fault.

A restore fenced behind a timed-out predecessor is no longer reported as
an ordinary in-flight restore. It carries `reason:
awaiting_abandoned_cleanup` and a retry hint of one restore budget
(capped at 120s) instead of the ordinary 5 seconds, because the fence
cannot clear until the non-cancellable ACP request settles and a 5-second
cadence just spins the caller against a 409 it cannot resolve.

Whether a channel is condemned is now derived rather than sticky. A
timeout recorded `emptyReapPending` permanently, so any channel that had
ever seen one was guaranteed to be reaped once its remaining work
drained, forcing a cold respawn even when the late restore had landed and
closed cleanly. The reap condition is now computed from an outstanding
`unsettledAbandonedRestores` set, quarantine, or an ordinary pending
empty reap; real settlement clears the entry and hands the channel back
to the configured idle policy.

Abandonment no longer retains ownership without bound. One further
restore budget after the deadline, a still-unsettled restore marks the
channel `restoreSettlementOverdue`: existing sessions and workspace
control keep working, but fresh session work is refused so the channel
can drain, since closing the transport is the only lever that releases a
permanently hung request. Releasing capacity while hidden work runs would
allow unbounded oversubscription, and force-killing a channel with live
siblings would reintroduce the failure this work removes, so neither is
done. Fresh-admission blocking is now scanned across alive channels
rather than tracked in a single reference, so a second condemned channel
cannot silently displace the first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(serve): keep the abandoned restore lifecycle off ids it no longer owns

Two correctness gaps in the abandoned-restore machinery introduced by this
PR, both reported by automated review and both confirmed by mutation
testing (each new test fails when its fix is reverted).

A caller-supplied `sessionId` is used verbatim by the agent, but
`spawnOrAttach` never consulted `inFlightRestores`. A fresh spawn could
therefore take an id that a restore still owns, in either lifecycle phase.
The consequences were silent: `abandonedRestoreIds` suppresses session
updates, guardrail events, and child notifications, so the new session
would have registered successfully and then emitted nothing; and a late
`settleAbandonedRestore` would have closed and tombstoned it out from
under its owner. Such a spawn is now rejected with the same
`RestoreInProgressError` and reason the restore path uses, so the caller
gets the correct retry hint for whichever phase is holding the id.

The cleanup path is guarded independently, because the request-level check
only covers the id the caller asked for and a session registers under the
id the child returns. An abandoned restore never reaches
`createSessionEntry` — the deadline rejects before registration — so any
live entry under that id belongs to someone else. Cleanup now detects that
and returns without closing or tombstoning, releasing its own bookkeeping
instead.

The notification fence has no TTL and was only cleared by
`markRestoreInFlight`, which covers a subsequent restore and nothing else.
`createSessionEntry` now clears it for every registration route, so a
legitimate owner of the id is never handed a session that silently drops
everything the child sends it.

Also tightens two tests that could not observe the values they pin. The
SDK default restore timeout admitted any value in (30s, 70s]; it is now
split at the exact boundary, so collapsing the default onto the 60s server
budget — which would make the client abort race the daemon's own deadline
and cost the caller its structured 504 — fails. And the advertised-budget
propagation from capabilities through to the SDK call had no live-path
assertion; dropping the capabilities argument at the real call site left
every existing test green. The `as never` casts are replaced with typed
`DaemonCapabilities` values so a field rename fails typecheck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(serve): let a condemned channel drain without its wedged child

Merging main's active-work close protocol (QwenLM#8588) into this PR's abandoned
restore bound produced a deadlock that neither side has on its own, and the
conflict resolution was committed without running tests.

`maybeCloseIdleSession` now routes through `confirmChildUnheld`, which asks
the child whether it still holds work before closing a session nobody is
attached to. That is right in general and wrong for a channel this PR has
already condemned. `restoreSettlementOverdue` and quarantine exist precisely
because the child stopped being answerable, and their whole premise is that
visible work drains so the channel can be reaped — closing the transport is
the only thing that can release a restore we cannot cancel. Making that
drain depend on a round trip to the wedged child inverts it: a child stuck
in a non-cancellable restore is exactly the one that cannot reply inside
`ACTIVE_WORK_CLOSE_TIMEOUT_MS`, so the sessions never close, the channel
never drains, the reap never fires, and the bound never takes effect.

A channel condemned by the restore lifecycle now skips the round trip and
proceeds to local teardown. Nothing is attached to the session by then —
`maybeCloseIdleSession` gates on that — and the sibling-safety invariant is
untouched: this closes sessions whose clients have already left, it does not
force-kill a channel that still has live ones.

The regression test drives an overdue channel whose child never answers the
close-if-unheld probe and asserts the detach still reaps it. Reverting the
guard reproduces the deadlock as a test timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(serve): pin the restore-timeout contract the review found unasserted

Automated review identified eleven places where the restore-timeout work's
behavior was correct but unpinned — each with a mutation that ships green.
Every fix below was verified the same way: apply the mutation, watch the new
assertion fail, revert, watch it pass.

The timeout path's telemetry had no coverage at all, which is the sharpest
gap given that observability is what this work exists to deliver. A shared
recorder now asserts the public timeout result and its kill_empty-vs-
fence_shared signal, the late arrival, and the cleanup outcome for both the
closed and quarantined cases.

The deadline timer's cancellation on a successful restore was likewise
unpinned: deleting both `clearTimeout` calls kept the whole suite green,
while in production the stale timer fires one budget after a successful
restore and abandons a live session — fencing its frames, closing its event
bus, and emitting a spurious timeout. A success-path test now advances past
the deadline and asserts no second public result.

Three more bridge assertions proved less than they claimed: the concurrent-
restore case never checked that the abandoned restore settles, the
workspace-control case never checked that the deferred reap eventually
fires, and the resolver never pinned the accepting side of the MAX boundary
(a `>` to `>=` mutation rejects the largest legal delay at boot). The
workspace-control case also needed a positive channel idle budget, since
with the default zero the idle-timer kill substitutes for the reap junction
under test; its assertions are rewritten around the derived reap semantics
rather than the sticky flag they predate.

Outside the bridge: the scheduled-task timeout wiring had no test, so
deleting the arguments silently fell back to the helpers' own defaults; the
cold restore path never asserted that `live_restore_ms` is absent; the SDK's
per-request validation and its over-ceiling clamp were untested; the WebUI
watchdog test jumped straight to its own value, staying green for any
watchdog at or below it, including the 30s attach value that would recreate
the original symptom in the browser; and the two new known error types were
unexercised, so dropping either would relabel every restore-timeout and
quarantine error as unknown.

Two review items are deliberately not taken here and are recorded in the
design doc's non-goals instead: transcript materialization is still not
separately attributable from `config_setup`, which needs instrumentation
inside the core session loader that P1/P2 restructures anyway, and sibling
event-loop latency during a large restore remains unmeasured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(serve): bound the condemned-channel close and complete the fence contract

Second automated review round, on the code the first round produced. One
Critical and twelve suggestions; all verified by mutation before and after.

**The Critical is a regression I introduced.** Letting a condemned channel
skip the bounded hold probe routed it into `closeSessionImpl`, whose agent
close is unbounded when it throws on failure — so the fix traded a bounded
wait on a wedged child for an unbounded one. A settlement-overdue channel
with an unresponsive child would hang `detachClient` forever, strand the
session in `closing`, never drain, never reap, and 503 every new session
until restart: strictly worse than before. `CloseSessionOpts` now carries an
`agentCloseTimeoutMs` that the condemned path sets, so a hang lands in the
existing unknown-outcome recovery, which kills the channel — the teardown
the drain was waiting for. The earlier test missed this because its fake
child still answered the plain close; it now answers nothing at all, and
asserts the detach itself returns.

**The fence was invisible on the transports clients actually use.**
`toRpcError` had no `RestoreInProgressError` case, so over acp-http and
acp-ws — which SDK negotiation prefers over REST — the fence degraded to an
opaque internal 500 with no code, reason, or hint, and the backoff contract
this work documents was impossible to honor.

**Two retry hints still advertised five seconds for states that outlive a
budget.** The restore 504 creates the fence, and quarantine lasts until the
channel drains; a fresh-id caller never reaches the 409 that carries the
real hint, so its header was the only signal it got. Both now derive from
the budget through one shared clamp helper, which also replaces the formula
that was inlined in the bridge and gives the documented 5-120s bounds a
test.

**A spawn collision reported an operation the caller never issued**, naming
the restore owner's action as both the active and the requested one and
telling the caller to retry an endpoint it never called.

The rest: five places still described the initialize-timeout fallback as a
plain chain rather than raise-only, contradicting sibling docs shipped in
this same PR; the design doc omitted the retry-hint clamp; the protocol
reference omitted the new spawn emission site; the error taxonomy omitted
`restore_settlement_overdue`, which matters because its audience is
monitoring. Test-only gaps: the dynamic 409 had no HTTP-layer coverage, the
120-second cap was unpinned, and the SDK's precedence of an explicit global
timeout over the advertised budget was pinned only branch-by-branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(serve): preserve restore session ownership handoff

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

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
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