Skip to content

fix(core): time out stalled background agents - #11270

Open
yiliang114 wants to merge 36 commits into
mainfrom
codex/issue-8586-agent-watchdog
Open

fix(core): time out stalled background agents#11270
yiliang114 wants to merge 36 commits into
mainfrom
codex/issue-8586-agent-watchdog

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds fixed progress watchdogs to ordinary background Agent turns, including fresh launches, restored runs, and resident continuations. Model/control work times out after 15 minutes without observable progress; each executing tool has its own 10-minute progress deadline. Tool output and silent-shell liveness updates renew only that tool's deadline.

A watchdog expiry cooperatively aborts the turn and is reported as TIMEOUT, then persisted and notified once as failed. It never enters the workflow retry loop. If the turn ignores that abort, the registry retains its physical slot while the daemon drains and replaces the owning Session runtime generation; existing Sessions stay pinned to their owner generation and fresh work uses the active replacement.

Why it's needed

A background Agent can remain registered as running while its model, control flow, or a tool has stopped making progress. The existing workflow watchdog cannot be reused because it retries and deliberately suspends timing for every running tool. This leaves ordinary background Agents able to wedge indefinitely and keep their Session active without a terminal result.

Approval waits pause the affected tool deadline. A no-tool round waiting for Monitor-owned external input pauses the model deadline until input arrives. Queued tools do not start their deadline before execution, and a timer delayed by host suspend or a local event-loop gap is rearmed instead of being charged to the Agent.

Reviewer Test Plan

How to verify

  1. Start an ordinary background Agent whose model/control path stops producing events. Confirm it aborts once at the fixed model/control deadline, emits a TIMEOUT finish, persists failed, and is not retried.
  2. Start parallel tools and hold one queued behind execution. Confirm only executing tools own a tool deadline; output or shell heartbeat events renew the matching deadline independently.
  3. Park a background tool on approval for longer than its deadline, then approve it. Confirm no timeout occurs while parked and timing resumes when execution starts.
  4. Let a background Agent enter a Monitor-owned external-input wait, then deliver a notification. Confirm the model deadline remains paused during the wait and resumes after delivery.
  5. Repeat with a restored Agent and a resident continuation. Confirm both use the same behavior, while workflow dispatch and foreground Agents retain their existing policy.\n6. Let a run ignore cooperative abort through the escalation grace. Confirm its physical slot remains accounted for, its terminal notification is recorded without starting a model turn, the owner generation drains, and fresh work moves to an active replacement.

Evidence (Before & After)

Before: an ordinary background Agent has no logical progress deadline; the workflow watchdog is retrying and treats all running tools as unbounded.

After: ordinary background turns have independent model/control and per-tool deadlines and settle cooperative stalls once as TIMEOUT / failed.

Tested on

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

Environment (optional)

Static diff review and formatting only. No local test, build, typecheck, or CI command was run.

Risk & Scope

  • Main risk or tradeoff: the fixed deadlines may terminate a genuinely silent model or tool after its full window; progress events renew the relevant deadline and explicit waits pause it.
  • Included in this consolidated PR: runtime-generation draining and escalation for an Agent that ignores cooperative abort.
  • Breaking changes / migration notes: one additive internal Agent event is introduced; there is no setting, persistence migration, or public timeout option.

Linked Issues

Part of #8586.

Depends on #11265.

中文说明

本 PR 做了什么

为普通后台 Agent 的每个 turn 增加固定进度 watchdog,覆盖首次启动、恢复运行和驻留 Agent 的后续继续。模型/控制流程连续 15 分钟没有可观察进度时超时;每个正在执行的工具各自拥有 10 分钟进度期限。工具输出和静默 shell 存活更新只续期对应工具的期限。

watchdog 到期后会协作式中止该 turn,并上报为 TIMEOUT,随后只持久化和通知一次 failed。它不会进入 workflow 的重试循环。如果该 turn 忽略中止,registry 会保留其物理槽位,同时 daemon 排空并替换该 Session 所属的 runtime generation;已有 Session 继续固定在原 owner generation,新任务使用 active replacement。

为什么需要

后台 Agent 的模型、控制流程或某个工具停止推进时,registry 仍可能一直把它记录为 running。现有 workflow watchdog 不能直接复用,因为它会重试,而且会对所有运行中的工具暂停计时。结果是普通后台 Agent 可能无限卡住,并持续占用 Session,且永远没有终态结果。

审批等待会暂停对应工具的期限。无工具 round 在等待 Monitor 所属外部输入时,会暂停模型期限,直到输入到达。排队中的工具不会在真正执行前开始计时;由主机休眠或本地事件循环卡顿导致的延迟定时器会重新计时,而不是归咎于 Agent。

Reviewer Test Plan

如何验证

  1. 启动一个模型/控制路径停止产生活动的普通后台 Agent。确认它在固定模型期限到达时只中止一次,发出 TIMEOUT finish,持久化为 failed,并且不重试。
  2. 启动并行工具,并让其中一个排队等待执行。确认只有正在执行的工具拥有工具期限;输出或 shell heartbeat 只独立续期对应工具。
  3. 让后台工具在审批上停留超过工具期限,然后批准。确认审批等待期间不会超时,开始执行后恢复计时。
  4. 让后台 Agent 进入 Monitor 所属外部输入等待,再投递通知。确认等待期间模型期限暂停,投递后恢复。
  5. 对恢复的 Agent 和驻留 Agent 后续 turn 重复验证。确认两者行为一致,而 workflow dispatch 和前台 Agent 保持原策略。\n6. 让一个 run 在升级宽限期后仍忽略协作式中止。确认它的物理槽位继续计入占用,终态通知在不启动模型 turn 的情况下被记录,owner generation 进入 draining,且新任务转移到 active replacement。

证据(Before & After)

Before:普通后台 Agent 没有逻辑进度期限;workflow watchdog 会重试,并把所有运行工具视为无界等待。

After:普通后台 turn 拥有独立的模型/控制和逐工具期限,协作式停滞只会结算一次为 TIMEOUT / failed

已测试平台

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

环境(可选)

只做了静态 diff 复核和格式化。未运行本地测试、build、typecheck 或 CI 命令。

风险与范围

  • 主要风险或取舍:固定期限会在完整窗口后终止确实长期静默的模型或工具;进度事件会续期对应期限,明确等待态会暂停期限。
  • 已包含在当前合并后的 PR 中:runtime generation draining,以及 Agent 不响应协作式中止时的升级处理。
  • 破坏性变更 / 迁移说明:新增一个内部 Agent 事件;没有设置项、持久化迁移或公开 timeout 配置。

关联 Issue

属于 #8586 的一部分。

依赖 #11265

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Re-run at the current head. This PR has moved a long way since the last triage pass at 25df3993, so what follows replaces that assessment rather than restating it.

Template looks good ✓ — every required heading is present and filled in.

Problem: observed, not theoretical. attachStallWatchdog is only reachable through runStallResilient, so an ordinary background Agent turn genuinely has no progress deadline of any kind and can sit registered as running with no terminal result forever — that is the open gap in #8586. It also no longer rests on static reading alone: @wenshao's harness measured the merge-base arm at running after 60s with the request still held open, against failed at 12.04s with the watchdog on. The evidence gap that dominated the last pass is closed.

Direction: aligned — a planned layer of #8586, authored by a repo admin. The stack is now retargeted at main, so the "no CI at all on a feature-branch base" problem from the last pass is gone and ordinary CI runs on this head.

Size: core paths across four packages. Production logic 944 linesbridge.ts 136, agent.ts 108, agent-core.ts 108, the new watchdog module 250, Session.ts 73, background-tasks.ts 51, background-agent-resume.ts 46, the rest spread over 14 files. Tests 209 (agent-progress-watchdog.test.ts 150, background-tasks.test.ts 40, bridge.test.ts 19). Docs 102 (two design notes, both bilingual). Generated/schema 0. That is over the 500-line maintainer-awareness mark, but this is maintainer-authored, so the two-tier core gate does not apply and I am not escalating on size. Under the 1000-line large-PR advisory.

Approach: both questions I raised last time are answered. Reusing attachStallWatchdog would have been the wrong call — its retry contract and its suspend-on-TOOL_CALL behavior are precisely what this path must not inherit, and the separate module now carries its own tests. The unrelated Prettier churn is gone and Lint & Static is green at this head. One thing I will still say out loud without re-litigating it: this PR carries two mechanisms — the progress watchdog, and a three-state runtime-generation model with a new child→daemon control route, a 503 error class and a fresh-work admission ceiling. R1-7 already raised the bundling and the author deferred it under the review-round scope cap, which is a legitimate call at round six. I flag it only because the one finding below lives in the second mechanism, not the first.

Risk: Stage 1e matches — packages/cli/src/acp-integration/session/Session.ts is on the revert-correlated path list. So no Stage 2 enrichment is skipped, CI evidence is required before any approval, and a sandboxed lane is named in Stage 2. A verification run is already in flight on this head.

Moving on to code review. 🔍

中文说明

在当前 head 上重跑。自上次 25df3993 的 triage 以来这个 PR 已经推进了很多,所以下面的内容是对上次判断的替换,不是重复。

模板完整 ✓ —— 所有必填小节都在且都写了内容。

问题: 已观测到的,不是理论性的。attachStallWatchdog 只能通过 runStallResilient 到达,所以普通后台 Agent 的 turn 确实完全没有任何进度期限,可以一直挂在 running 状态且永远没有终态结果——这正是 #8586 里尚未关闭的缺口。而且它现在也不再只依赖静态阅读:@wenshao 的实测 harness 量到 merge-base 分支在 60 秒后仍是 running、请求还被挂着,而开启 watchdog 后是 12.04 秒 failed。上次占主导地位的证据缺口已经关闭。

方向: 对齐——这是 #8586 计划中的一层,作者是仓库 admin。这个栈现在已经把 base 改回 main,所以上次"以功能分支为 base 导致完全没 CI"的问题消失了,常规 CI 在这个 head 上会跑。

规模: 跨四个包触及核心路径。生产逻辑 944 行——bridge.ts 136、agent.ts 108、agent-core.ts 108、新的 watchdog 模块 250、Session.ts 73、background-tasks.ts 51、background-agent-resume.ts 46,其余分散在 14 个文件。测试 209 行agent-progress-watchdog.test.ts 150、background-tasks.test.ts 40、bridge.test.ts 19)。文档 102 行(两份设计说明,双语齐全)。生成/schema 0 行。这超过了 500 行的"维护者知悉"线,但本 PR 由维护者提交,因此双层核心门禁不适用,我不会基于规模升级处理。低于 1000 行的大 PR 建议线。

方案: 上次我提的两个问题都有了答复。复用 attachStallWatchdog 其实是错的方向——它的重试契约和"在 TOOL_CALL 时暂停计时"的行为恰恰是这条路径不能继承的,而现在这个独立模块自带了测试。与本 PR 无关的 Prettier 重排版已经去掉,Lint & Static 在这个 head 上是绿的。有一点我还是要说出来,但不打算重新争论:这个 PR 装了两套机制——进度 watchdog,以及一个三状态的 runtime generation 模型,外加新的 child→daemon 控制路由、一个 503 错误类和一套新工作准入上限。R1-7 已经提过捆绑问题,作者在 review 轮次上限下明确将其延后,在第六轮这是合理的处理。我之所以还要点出来,只是因为下面唯一的那个发现位于第二套机制里,而不是第一套。

风险: Stage 1e 命中——packages/cli/src/acp-integration/session/Session.ts 在与 revert 相关的路径清单上。因此 Stage 2 的任何 enrichment 都不跳过,任何批准之前都必须有 CI 证据,并且 Stage 2 里会点名沙箱验证通道。当前 head 上已经有一个验证任务在跑。

进入代码审查 🔍

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

My independent proposal before reading the diff was the same one I wrote last pass — one idle timer per turn, armed at turn start, reset by any observable progress event, aborting through the existing controller and settling once as failed. The PR remains meaningfully more ambitious, and the extra machinery continues to earn its place: per-tool deadlines, the two nested-wait pauses, the typed abort reason, and the retained-physical-slot accounting are all things a single idle timer gets wrong.

The five asks from the last pass are genuinely closed, and I verified the wiring rather than taking the commit messages for it.

  1. Transport backoff. MODEL_RETRY is now emitted from both places a retry can originate — the llm-chat transport hook and the stream-side retry branch. I checked the new onRetry actually lands where it should: sendMessageStream(model, params, prompt_id, goalContext?, options?), and the call site passes undefined then { onRetry }, so positions 4 and 5 are correct. A mis-placed argument here would have silently reproduced the original bug.
  2. The round-2 regression @wenshao found. onModelRetry no longer clears the deadline and waits for activity that may never come — it calls armModel(event.retryDelayMs), re-arming for the base window plus the reported backoff capped at six hours. A retry followed by a hang is caught again.
  3. Silent tool charged to the model deadline. onToolCallsUpdate now emits TOOL_PROGRESS on the real executing transition and a settled variant on success/error/cancelled. I confirmed the scheduler supplies both: coreToolScheduler.ts:1816 sets status: 'executing' and :6475 calls onToolCallsUpdate([...this.toolCalls]). Because armModel() early-returns while any tool is executing or parked, the parallel-tool sub-issue is gone too — a sibling finishing no longer re-arms the model deadline mid-batch.
  4. Tests. Six fake-timer tests cover both deadlines and every pause condition, including the two negative cases I asked for by name. The registry escalation-vs-task_stop race and the bridge generation-slot admission are each pinned as well.
  5. Prettier churn and the base branch. Both resolved; Lint & Static is green at this head.

I also traced the newest commit (park the parent watchdog during nested waits), since it is the least-reviewed code here. The emitting half is correct: updateDisplay merges into currentDisplay, so the 1 Hz forwardProgress heartbeat preserves the parked flags instead of dropping them, and task_execution really is AgentResultDisplay's discriminant (tools.ts:646), so the narrowing in outputUpdateHandler is sound.

One finding blocks my approval — the recovery exemption is documented but not implemented

ensureChannel grew an admission: 'fresh' | 'recovery' parameter. It is read in exactly one place, inside a log string:

const workOwningGenerations = [...aliveChannels].filter((info) => info.state !== 'dying');
if (workOwningGenerations.length >= 2) {
  writeStderrLine(`qwen serve: runtime recycling blocked ${admission} work; generations=…`);
  throw new BridgeRuntimeRecyclingError();
}

The ceiling therefore blocks recovery identically to fresh. But background-agent-runtime-generations.md promises "Restore and recycle recovery may start a replacement while dying processes await reap", and bridgeTypes.ts documents requestRuntimeRecycle as "stop admitting fresh work to the generation that owns sessionId and prepare a replacement".

That matters because requestRuntimeRecycleForSession marks the owner draining before asking for the replacement:

owner.state = 'draining';
if (channelInfo === owner) cancelIdleTimer();
await retireChannelAfterSessionsDrain(owner, );
if (!owner.isDying) await ensureChannel('recovery');

So if a previous recycle's generation is still draining — non-dying, sessions still pinned — the count is already 2 when the second recycle asks for its replacement, and the spawn throws. The recycle is left half-applied: the owner is condemned, no active replacement exists, and because channelInfo is the newest generation and is no longer active, admissibleChannelInfo() returns undefined. Every fresh-work path then fails with 503 runtime_recyclingspawnOrAttach, generateWorkspaceAgent, and the runtime MCP add/remove routes, all of which this PR switched from liveChannelInfo() to admissibleChannelInfo().

And I could not find anything that bounds the wait. A draining generation is reaped only through hasNoChannelWork / channelShouldReapWhenIdle, both evaluated on session-departure and idle transitions; retireWhenSessionsDrain is a condemned flag, not a timer. Its sessions stay pinned by design. The condition that triggered the recycle in the first place — a run that ignores cooperative abort and keeps its physical slot — is the same condition that stops the generation from draining. The error's own hint, "retry after an older generation exits", can be unsatisfiable, and the throw is swallowed as a debug warning on the child side, so nothing surfaces it.

I want to be precise about what I am and am not claiming. I read every line cited above and I am confident about the code facts. What I cannot settle statically is reachability: it needs a second unresponsive Agent inside the replacement generation while the first generation has not exited. That may be rare, or there may be a bound I have not found. I am not permitted to execute this PR's code to find out.

sequenceDiagram
    participant P1 as ProgressWatchdog
    participant P2 as TaskRegistry
    participant P3 as Session child
    participant P4 as BridgeClient daemon
    participant P5 as AcpSessionBridge
    P1->>P1: deadline expires, abort turn
    Note over P1: five second cooperative grace
    P1->>P2: failUnresponsive, retains physical slot
    P2->>P3: terminal notification marked recordOnly
    P3->>P3: persist and display, no model turn
    P3->>P4: ext method session runtime recycle
    P4->>P5: requestRuntimeRecycle, owner scoped
    P5->>P5: mark owner generation draining
    P5->>P5: ensureChannel recovery
    Note over P5: ceiling counts non dying generations
    P5--xP3: throws 503 when two are draining
    Note over P5: owner condemned, no active replacement, fresh work 503s
Loading

Non-blocking

  • isChannelLive()'s rewritten doc now says "whether an ACP channel is active and can accept fresh workspace work", but the implementation (liveChannelInfo() = channelInfo && !isDying) still returns true for a draining generation. One of the two should move. Consumers are routes/health.ts:92 (runtimeChannelAlive) and the acpChannelLive env-status envelope, so during a recycle window the daemon reports a live channel while fresh work 503s.
  • armModel is passed as its own rearm callback, so a host-suspend rearm drops the retry extension (retryDelayMs defaults back to 0). A backoff that spans a suspend loses its extension and falls back to the base window. Bounded impact.
  • No test covers the emitting half of the nested-wait bridge — the agent.ts display forwarding or the outputUpdateHandler classification. The receiving half is well tested. I verified it by reading, but reading is what missed the round-2 regression, so a test here would be cheap insurance.
Files changed (28 of 28 shown)
File What changed
docs/design/background-agent-progress-watchdog.md New design note — two deadlines, pause conditions, no-retry contract
docs/design/background-agent-progress-watchdog.zh-CN.md Chinese counterpart, complete and in sync
docs/design/background-agent-runtime-generations.md New design note — three-state generations, admission ceiling, escalation
docs/design/background-agent-runtime-generations.zh-CN.md Chinese counterpart, complete and in sync
packages/core/src/agents/runtime/agent-progress-watchdog.ts The new stateful watchdog — model and per-tool deadlines, three pause conditions, rearm heuristic, escalation grace
packages/core/src/agents/runtime/agent-progress-watchdog.test.ts Six fake-timer tests over both deadlines and every pause condition
packages/core/src/agents/runtime/agent-events.ts Adds model_retry and tool_progress event types and payloads
packages/core/src/agents/runtime/agent-core.ts Emits retry and tool-progress events, maps abort reason to TIMEOUT at five sites
packages/core/src/agents/runtime/agent-headless.ts Surfaces the timeout message as final text instead of an empty result
packages/core/src/agents/background-tasks.ts failUnresponsive, retainsPhysicalSlot, recordOnly, slot accounting and pruning
packages/core/src/agents/background-tasks.test.ts Pins the escalation-versus-task-stop race and the recordOnly notification
packages/core/src/agents/background-agent-resume.ts Attaches the watchdog to restored and resident turns, honors retained slots
packages/core/src/tools/agent/agent.ts Attaches the watchdog on fresh launches, forwards nested-agent progress upward
packages/core/src/tools/tools.ts Two new parked-state fields on the agent display payload
packages/core/src/core/llm-chat.ts Threads an onRetry backoff callback through three call paths
packages/acp-bridge/src/bridge.ts Three-state generations, admission ceiling, recycle entry point — the finding above lives here
packages/acp-bridge/src/bridge.test.ts Frees a generation slot on child exit so admission is observable
packages/acp-bridge/src/bridgeClient.ts Handles the child-to-daemon recycle ext method with owner and reason validation
packages/acp-bridge/src/bridgeErrors.ts New runtime_recycling error class
packages/acp-bridge/src/bridgeTypes.ts Redocuments isChannelLive, declares optional requestRuntimeRecycle
packages/acp-bridge/src/status.ts Registers the private control ext-method name
packages/cli/src/acp-integration/session/Session.ts Records the terminal notification without a model turn, then requests recycle
packages/cli/src/nonInteractiveCli.ts Carries recordOnly items to the SDK but filters them out of the model batch
packages/cli/src/ui/hooks/use-llm-stream.ts Drops recordOnly notifications in the interactive TUI
packages/cli/src/ui/utils/backgroundWorkUtils.ts Names retained-slot entries as still stopping in the blocking-work list
packages/cli/src/serve/acp-http/dispatch.ts Maps the new error to a retryable 503 RPC response
packages/cli/src/serve/server/error-response.ts Maps the new error to a retryable 503 HTTP response
packages/cli/src/serve/acp-session-bridge.ts Re-exports the new error class

Test evidence

This is the PR's own CI at the reviewed commit, fetched through the API. I did not build, run or test any of this PR's code — the review is static, and executed code in this environment could read the agent's write token.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Test (macos-latest, Node 22.x) skipped
Test (windows-latest, Node 22.x) skipped
Lint & Static (ubuntu-latest, Node 22.x) success
Serve A/B (ubuntu-latest, Node 22.x) success
Integration Tests (no-AK, No Sandbox) success
Integration Tests (CLI, No Sandbox) skipped
TUI parity snapshots (ink vs opentui) success
OpenTUI no-flicker gate success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
Desktop Shell (ubuntu-22.04) / (windows-2022) success
Real daemon E2E / Java 11 success
SDK Java (ubuntu 11/17/21, macos 21, windows 21) success
Classify PR, assign, label, authorize, Remind on force-push success
review-pr in_progress (bot orchestration, not PR CI)

Every pull_request-event workflow run on this head has completed, so the pending count is 0 and it is 0 because CI is green, not because nothing was created — which was the situation last pass. No red checks, so there is no failing-job excerpt to quote. Two caveats worth stating plainly: the macOS and Windows unit jobs are skipped, so unit coverage is Ubuntu-only even though the PR body marks all three OS rows untested; and a green suite proves the tests pass, not that they pin the behaviour this PR claims.

Not verified: whether the watchdog fires exactly once end-to-end at the real 15/10-minute constants; whether TIMEOUT reaches the registry and sidecar as failed rather than cancelled; whether the recycle route produces a working replacement generation under a real daemon; and the reachability of the half-applied-recycle state described above. @wenshao's harness settled the first two at 25df3993 and refuted one of my static findings there, which is exactly why I would rather not extend those results to a head five commits later by assumption. The author's own notes record that no local test, build or typecheck was run — that is the author's claim, not evidence, and I have not re-run it.

Sandboxed verification would settle this, and the author has write access so both lanes are open. A run is already in flight on this head and will report into the qwen-triage:verify comment. @qwen-code /verify — specifically that a second recycle, issued while a previous generation is still draining with its sessions pinned, produces an active replacement rather than leaving the workspace serving 503 for fresh work; that is a claim about daemon admission under a state I can construct on paper but not reach by reading, and nothing in the added tests covers it. @qwen-code /tmux — that a wedged background Agent surfaces as a TIMEOUT notification in the real TUI instead of staying in the running list, and that a retained-slot entry renders as "still stopping".

中文说明

代码审查

在读 diff 之前,我的独立方案和上次一样——每个 turn 一个空闲定时器,turn 开始时启动,任何可观察进度事件都重置它,到期后通过已有 controller 中止并只结算一次 failed。这个 PR 依然比我的方案野心大得多,而且多出来的机制依然值得:逐工具期限、两个嵌套等待暂停、带类型的 abort reason、以及物理槽位保留的记账,都是单一定时器会做错的地方。

上次提的五点都真正关闭了,而且我核对的是接线本身,不是采信 commit message。

  1. 传输退避。 MODEL_RETRY 现在会在重试可能发生的两个位置都发出——llm-chat 的传输钩子和流式 retry 分支。我确认了新的 onRetry 落在正确的参数位上:sendMessageStream(model, params, prompt_id, goalContext?, options?),调用点传的是 undefined 然后 { onRetry },第 4、5 位正确。这里如果参数错位,就会静默地把原来的 bug 复现一遍。
  2. @wenshao 在第二轮发现的回归。 onModelRetry 不再是"清掉期限然后等待可能永远不会来的活动",而是调用 armModel(event.retryDelayMs),以基础窗口加上上报的退避(上限六小时)重新启动。"重试之后挂住"重新能被抓住了。
  3. 静默工具被算到模型期限头上。 onToolCallsUpdate 现在会在真正的 executing 转换时发出 TOOL_PROGRESS,并在 success/error/cancelled 时发出 settled 变体。我确认调度器两者都提供:coreToolScheduler.ts:1816 设置 status: 'executing':6475 调用 onToolCallsUpdate([...this.toolCalls])。由于 armModel() 在任一工具处于 executing 或 parked 时提前返回,并行工具的子问题也一并消失——一个兄弟工具结束时不会在批次中途重新启动模型期限。
  4. 测试。 六个 fake-timer 测试覆盖了两个期限和每一个暂停条件,包括我点名要求的两个反向用例。registry 的"升级 vs task_stop"竞态和 bridge 的 generation 槽位准入也各自被固定住了。
  5. Prettier 重排版与 base 分支。 都已解决;Lint & Static 在这个 head 上是绿的。

我也追踪了最新那个 commit(park the parent watchdog during nested waits),因为它是这里 review 最少的代码。发送侧是正确的:updateDisplay 是往 currentDisplay 上做合并,所以 1 Hz 的 forwardProgress 心跳会保留 parked 标志而不是把它丢掉;而且 task_execution 确实是 AgentResultDisplay 的判别字段(tools.ts:646),所以 outputUpdateHandler 里的类型收窄是成立的。

有一个发现让我无法批准——文档承诺的 recovery 豁免并没有实现

ensureChannel 新增了 admission: 'fresh' | 'recovery' 参数。它只在一个地方被读取,就是日志字符串里(见上方代码块)。因此这个上限对 recovery 的阻挡与对 fresh 完全相同。但 background-agent-runtime-generations.md 承诺"restore 和 recycle recovery 可以在 dying 进程等待回收时启动替代进程",bridgeTypes.ts 也把 requestRuntimeRecycle 描述为"停止向拥有该 sessionId 的 generation 准入新工作,并准备一个替代者"。

这一点之所以要紧,是因为 requestRuntimeRecycleForSession把 owner 标记为 draining去请求替代者的。所以如果上一次 recycle 的 generation 仍在 draining(非 dying、session 仍固定在其上),第二次 recycle 请求替代者时计数已经是 2,spawn 就会抛错。这次 recycle 就停在半应用状态:owner 已被判死,没有 active 替代者,而由于 channelInfo 是最新的 generation 且已不再 activeadmissibleChannelInfo() 返回 undefined。于是所有新工作路径都会以 503 runtime_recycling 失败——spawnOrAttachgenerateWorkspaceAgent,以及运行时 MCP add/remove 路由,而这些正是本 PR 从 liveChannelInfo() 切换到 admissibleChannelInfo() 的地方。

而且我找不到任何能给这个等待设上限的东西。draining generation 只能通过 hasNoChannelWork / channelShouldReapWhenIdle 被回收,两者都是在 session 离开和 idle 转换时求值的;retireWhenSessionsDrain 是一个"判死"标志,不是定时器。它的 session 按设计仍然固定在原处。而最初触发 recycle 的那个条件——一个忽略协作式中止、继续占用物理槽位的 run——恰恰就是让这个 generation 无法排空的条件。错误自带的提示"retry after an older generation exits"可能是永远无法满足的,而这个抛错在 child 侧只被当作 debug warning 吞掉,所以什么都不会浮现出来。

我想精确说明我在声称什么、不在声称什么。上面引用的每一行我都读过,代码事实我有信心。我静态无法定论的是可达性:它需要在替代 generation 内出现第二个无响应 Agent,同时第一个 generation 还没有退出。这可能很罕见,也可能存在我没找到的上限。我不被允许执行这个 PR 的代码去验证。

不阻塞合并

  • isChannelLive() 重写后的文档说"某个 ACP channel 是否 active 且能接受新的工作区工作",但实现(liveChannelInfo() = channelInfo && !isDying)对 draining generation 仍返回 true。两者应该改一个。消费方是 routes/health.ts:92runtimeChannelAlive)和 acpChannelLive 环境状态封装,所以在 recycle 窗口内,daemon 会报告 channel 存活,而新工作却拿到 503。
  • armModel 把自己当作 rearm 回调传入,所以主机休眠导致的重新计时会丢掉重试延长量(retryDelayMs 回落到 0)。跨越一次休眠的退避会失去延长量、退回基础窗口。影响有界。
  • 嵌套等待桥接的发送侧没有测试——agent.ts 的 display 转发和 outputUpdateHandler 的分类都没有。接收侧测得很好。我是靠阅读确认的,但阅读正是漏掉第二轮回归的那种手段,所以这里补一个测试是很便宜的保险。

测试证据

上面是被审查 commit 上本 PR 自己的 CI,通过 API 获取。我没有 build、运行或测试这个 PR 的任何代码——审查是静态的,而且在这个环境里执行代码可能读到 agent 的 write token。

这个 head 上所有 pull_request 事件的 workflow run 都已完成,所以待完成数为 0,而且这个 0 是因为 CI 全绿,不是因为什么都没被创建——上次恰恰是后一种情况。没有红色检查,所以没有失败日志可引用。有两点需要直说:macOS 和 Windows 的单测 job 是 skipped,所以尽管 PR 正文把三个 OS 都标为未测试,单测覆盖实际只有 Ubuntu;而且绿色套件证明的是测试通过,不是测试固定住了本 PR 声称的行为。

未验证的部分: watchdog 在真实的 15/10 分钟常量下是否端到端只触发一次;TIMEOUT 是否以 failed 而非 cancelled 抵达 registry 和 sidecar;recycle 路由在真实 daemon 下是否产出一个可用的替代 generation;以及上面那个半应用 recycle 状态的可达性。@wenshao 的 harness 在 25df3993 上定论了前两项,并且推翻了我在那里的一个静态发现——这正是我不愿意把这些结论靠假设外推到五个 commit 之后的 head 上的原因。作者自己的说明记录了没有跑本地测试、build 或 typecheck——那是作者的陈述,不是证据,我也没有代其重跑。

沙箱验证可以定这件事,而且作者有 write 权限,两条通道都可用。当前 head 上已经有一个验证任务在跑,结果会发布到 qwen-triage:verify 评论里。@qwen-code /verify——具体要定的是:在上一个 generation 仍带着固定 session 处于 draining 时发出的第二次 recycle,是否会产出一个 active 替代者,而不是让整个工作区在新工作上一直返回 503;这是一个关于 daemon 准入在某种状态下的断言,那种状态我能在纸面上构造出来,却无法靠阅读到达,而新增测试里没有任何一个覆盖它。@qwen-code /tmux——卡死的后台 Agent 是否在真实 TUI 中呈现为 TIMEOUT 通知而不是一直留在 running 列表里,以及保留槽位的条目是否渲染为"still stopping"。

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the watchdog itself is now in good shape and every prior ask is verifiably closed, but I found one daemon-admission path I cannot finish reasoning about statically, and a verification run on this exact head is still in flight.

Stepping back. Last pass I scored this 2/5, and the honest summary of what changed is that the author did the work rather than arguing about it. Both correctness paths I named are closed at the wiring level, not just in intent — I checked the onRetry argument positions against sendMessageStream's signature and the executing transition against the scheduler, because either could have been a fix that looks right and does nothing. The round-2 regression @wenshao caught with a real harness, which no amount of diff reading found, is fixed in the better of the two ways he suggested: the deadline is re-armed for the known backoff instead of being cleared and hoped for. Tests now exist for both deadlines and every pause condition, including the two negative cases. The base is main, so CI runs and is green. All inline threads are resolved.

So the watchdog — the thing the title names — I would merge. My reservation is entirely in the second mechanism this PR carries, and it is narrow enough that I want to be careful not to overstate it.

ensureChannel's new admission parameter reaches only a log string, so the fresh-work ceiling also blocks recycle recovery, which the design doc says it should not. Because requestRuntimeRecycleForSession condemns the owner generation before asking for a replacement, a second recycle issued while the first generation is still draining throws, and the throw leaves the workspace with a condemned generation, no active replacement, and a 503 on every fresh-work path. I could not find a bound on when a draining generation exits, and the condition that triggers a recycle is the same condition that keeps it from draining.

Two reasons I am deferring rather than requesting changes. First, I am confident about the code and not about the reachability — it needs a second unresponsive Agent in the replacement generation while the first has not exited, and I am not permitted to run this PR's code to find out whether that is a real shape or a paper one. Second, this PR is at round six, and the project's own rule at this point is to land Critical fixes and defer the rest; stacking a sixth CHANGES_REQUESTED onto a PR that is already gated, on a finding I cannot fully substantiate, adds noise rather than signal. If a maintainer reads the code and concludes the state is unreachable, or that a 503-with-retry is an acceptable answer for it, this is a merge.

What would settle it, in ascending order of cost: a line in the design doc stating whether recovery is meant to be exempt from the ceiling, and if it is, gating the throw on admission === 'fresh'; or a bound on how long a generation may sit draining before it is force-retired; or the @qwen-code /verify run already in flight on this head being pointed at the two-recycle sequence specifically. The three non-blocking items in Stage 2 — isChannelLive's doc contradicting its implementation, the rearm dropping the retry extension, and the untested emitting half of the nested-wait bridge — can ride along or land on #8586, whichever is cleaner. None of them should hold this up.

One last thing, said once and then dropped: at 944 production lines across four packages this PR ships two mechanisms, and the finding above is in the one that is not in the title. R1-7 raised the bundling and the author deferred it under the round cap, which was a legitimate call. I am not reopening it — I am noting that the cost of bundling showed up exactly where it usually does, in the half of the diff that had accumulated the least review attention.

@wenshao — deferring to you. You have the harness and the runtime history on this PR, and the open question is a runtime one: can a second recycle land while a previous generation is still draining with pinned sessions, and if it does, does the workspace recover? Note for the record that the deterministic maintainer resolver could not pick a name here — this PR carries no labels, so the label-driven area match finds nothing, and the last-resort "most recent human reviewer" resolves to the author. You are the first-listed core area owner and the maintainer who has been verifying this PR, so the call is yours.

中文说明

Confidence: 3/5 —— watchdog 本身现在状态不错,之前提的每一点都已可核实地关闭了,但我发现了一条 daemon 准入路径,静态推理走不完,而且当前这个 head 上的验证任务还在跑。

退一步看整体。上一轮我给的是 2/5,而这次变化的诚实总结是:作者是把活干了,而不是来争论的。我点名的两条正确性路径都在接线层面关闭了,不只是意图层面——我把 onRetry 的参数位置和 sendMessageStream 的签名对过,也把 executing 转换和调度器对过,因为这两处都可能是"看起来对、实际什么都不做"的修复。@wenshao 用真实 harness 抓到、而反复读 diff 都没发现的第二轮回归,是用他建议的两种方案里更好的那一种修的:期限按已知退避重新启动,而不是清掉然后指望活动会来。两个期限和每个暂停条件现在都有测试,包括那两个反向用例。base 是 main,所以 CI 会跑而且是绿的。所有 inline thread 都已 resolved。

所以 watchdog——也就是标题点名的那个东西——我是愿意合的。我的保留意见完全在本 PR 携带的第二套机制里,而且足够狭窄,所以我想小心不要夸大它。

ensureChannel 新增的 admission 参数只到达一个日志字符串,所以新工作上限同时也阻挡了 recycle recovery,而设计文档说它不该阻挡。由于 requestRuntimeRecycleForSession判死 owner generation、请求替代者,所以在上一个 generation 仍在 draining 时发出的第二次 recycle 会抛错,而这个抛错会让工作区停在"generation 已判死、没有 active 替代者、所有新工作路径都 503"的状态。我找不到 draining generation 何时退出有任何上限,而触发 recycle 的条件恰恰就是让它无法排空的条件。

我选择 defer 而不是 request changes,有两个原因。第一,我对代码有信心,对可达性没有——它需要在替代 generation 里出现第二个无响应 Agent,同时第一个还没退出,而我不被允许运行这个 PR 的代码去判断这是真实形态还是纸面形态。第二,这个 PR 已经在第六轮,项目自己的规则是此时只落 Critical、其余延后;在一个已经被 gate 住的 PR 上再叠第六个 CHANGES_REQUESTED,而且依据是一个我无法完全证实的发现,那是增加噪音而不是信号。如果维护者读完代码后判断这个状态不可达,或者认为对它返回可重试的 503 就是可接受的答案,那这个 PR 就可以合。

按成本从低到高,能定这件事的做法是:在设计文档里写一句 recovery 是否应当豁免于这个上限,如果应当,就把抛错限定在 admission === 'fresh';或者给一个 generation 在 draining 状态停留多久后强制退役的上限;或者把当前 head 上已经在跑的 @qwen-code /verify 明确指向"两次 recycle"这个序列。Stage 2 里那三个不阻塞的项目——isChannelLive 的文档与实现相矛盾、rearm 丢掉重试延长量、以及嵌套等待桥接发送侧没有测试——可以在这里一起处理,也可以放到 #8586 上,看哪种更干净。它们都不该拖住这个 PR。

最后一点,说一次就不再提:944 行生产代码跨四个包,这个 PR 装了两套机制,而上面那个发现位于不在标题里的那一套。R1-7 提过捆绑问题,作者在轮次上限下将其延后,那是合理的处理。我不重开这个话题——我只是指出,捆绑的代价恰好出现在它通常出现的地方:diff 里累积 review 注意力最少的那一半。

@wenshao —— 转交给你。你有 harness,也有这个 PR 的运行时历史,而这个未决问题是一个运行时问题:第二次 recycle 是否可能在上一个 generation 仍带着固定 session 处于 draining 时落地,如果落地了,工作区能否恢复?为了留下记录说明一下:确定性的维护者 resolver 在这里没能选出名字——这个 PR 没有任何 label,所以基于 label 的 area 匹配什么都没匹配到,而最后兜底的"最近一位人类 reviewer"解析出来的是作者本人。你是 core area owner 列表里的第一位,也是一直在验证这个 PR 的维护者,所以这个判断归你。

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

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Needs some rethinking before this goes further — see my notes above. 🙏

Two paths abort a healthy Agent: the 15-minute model deadline fires during transport rate-limit backoff (invisible to the watchdog, and llm-chat's delay() honours the abort, so it interrupts a retry that was working as designed), and a tool that executes without streaming output stays queued and gets charged to that same model deadline. Since the contract is settle-once with no retry, a false positive doesn't degrade a background Agent, it destroys it and reports a misleading reason.

Nothing has executed this code either: no tests were added for the new 202-line timing module, and because ci.yml gates pull_request on base main or release/**, no CI ran on a feature-branch base. #11273 is already stacked on this head branch, so both findings propagate upward.

The design is right — I'd rather see it land with those closed than see it rewritten.

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

@wenshao

wenshao commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Runtime verification of PR #11270 (maintainer review)

I built a real local environment and ran this PR end-to-end against a real background Agent, rather than reviewing the diff. Reviewed at 25df3993ab205790bbb73cd5d71a42d42820edd8.

Verdict: the mechanism works and is wired correctly, but one scenario is a regression that I think blocks merge — a healthy background Agent sitting in ordinary HTTP 429 transport backoff is killed and settled as failed, permanently, with no retry. I also refute the static review's Finding 2: non-streaming tools do get their per-tool deadline.


Harness

Real dist/cli.js bundle built from the PR head, driven in a real TUI under tmux, isolated HOME, scripted OpenAI-compatible server, and a real stdio MCP server whose tool never returns. To make deadlines observable in seconds I patched only the two constants in the built bundle — 15 * 6e4 → 12e3 and 10 * 6e4 → 8e3. No logic was changed.

The merge-base arm is the same bundle with attachAgentProgressWatchdog replaced by () => {}. That is an exact behavioral revert: every other hunk in this PR is gated on getAgentProgressTimeout(signal) returning a value, which can only happen if the watchdog aborted.

A separate instrumented arm subscribes a logger to all eleven events the watchdog listens for, so the state machine's inputs are captured directly rather than inferred.


What I verified works

Scenario PR arm Merge-base arm
Model wedged (server holds the request open) failed @ 12.04s, lastError: "Background agent made no model/control progress for 12000ms." running @ 60s, request still held
Non-streaming MCP tool hangs failed @ 8.36s, lastError: "…tool \"mcp__hangsrv__hang_forever\" made no progress for 8000ms."
Silent 30s shell command, heartbeat 2s completes normally @ 30.44s

The whole chain holds: watchdog → AgentProgressTimeoutError on the turn controller → TIMEOUT terminateMode → registry/sidecar failed → exactly one TUI notification, no retry. The abort also propagates to the in-flight HTTP request (the fake server logs the client closing the held connection 11.9s after it was opened). Both the fresh-launch and resident-continuation wrappers are covered.


PR — settles as failed at the deadline

Merge-base — same wedge, still ticking at 60s

The run_shell_command heartbeat row matters: a silent command 3.75× longer than the tool deadline ran to completion, renewed by 14 tool_progress heartbeats. The renewal claim in the PR description is real, and there is no false-positive kill of a legitimately silent long command.


Blocking: a healthy Agent in transport backoff is killed

Scripted the subagent's provider to answer HTTP 429 with Retry-After: 45 — an ordinary, fully recoverable throttle.

+0.00s  ATTACH
+0.04s  start / round_start        -> model deadline armed
+0.21s  <429 Retry-After: 45>      transport backoff begins
        ... no watchdog event of ANY kind for the entire backoff ...
+45.40s settled: failed
  • PR arm: failed, lastError: "Background agent made no model/control progress for 12000ms."
  • Merge-base arm: still running at 60s, 2 retries issued, recovering exactly as designed.

PR — healthy throttled agent killed

Merge-base — survives and retries

agent-core.ts handles the retry stream event by resetting per-attempt state and emits nothing on the AgentEventEmitter, so the entire backoff is invisible to the watchdog. With the shipped constants this is not a corner case: RATE_LIMIT_RETRY_OPTIONS in llm-chat.ts is {maxRetries: 10, initialDelayMs: 60000, maxDelayMs: 300000}, so the sleep ladder reaches 1020s cumulative by retry 5 of 10 — already past the 900s deadline with five retries still to go — and retry.ts caps a single Retry-After wait at PERSISTENT_CAP_MS = 6 hours. Background Agents are the long-lived things most likely to hit a quota window, and this watchdog deliberately never retries, so one throttle blip becomes a permanently dead Agent whose notification says it made no progress while it was in fact retrying as designed.

One correction to the static review's mechanism. It states that delay() honours the abort signal, so the watchdog "actively interrupts" the backoff. That is not what happens. The abort fires at the deadline but the sleep is not interrupted — settlement tracks Retry-After exactly: Retry-After: 30 → settled at 30.4s, Retry-After: 45 → settled at 45.4s (two runs). The retry loop only observes the abort after the full sleep elapses. The outcome is the same; the timing is not.

Either emit an event from the retry branch, or pause the model deadline while a backoff is in flight.


Refuted: Finding 2 (silently-executing tools charged to the model deadline)

The static review argues that only tools streaming live output ever reach state: 'executing', so a slow MCP call is charged to the 15-minute model deadline and aborted as "no model/control progress". I had the same hypothesis from reading the code. The real run disproves it.

+0.37s  tool_call             callId=call_hang_1  name=mcp__hangsrv__hang_forever
+0.37s  tool_output_update    callId=call_hang_1     <- execution-start emit
+8.37s  tool_result           callId=call_hang_1
=> lastError: Background agent tool "mcp__hangsrv__hang_forever" made no progress for 8000ms.

agent-core.ts:2070-2084 emits a TOOL_OUTPUT_UPDATE on the → executing transition for every tool, streaming or not (it exists so the agent view can offer Ctrl+F and start the elapsed timer before first output). That is the execution-start signal the review says the event stream lacks. My MCP server sends no progress notifications at all and still got its own per-tool deadline, with the correct tool phase and tool name. The same emit also fires on awaiting_approval → executing, so the "approved tool is then unwatched" variant does not occur either.

Worth noting as a fragility rather than a bug: the tool deadline is armed by an event whose stated purpose is UI, and neither the code nor docs/design/background-agent-progress-watchdog.md says so. If that emit is ever made conditional, every tool silently loses its deadline. A comment at the onToolProgress handler naming agent-core.ts:2076 as the load-bearing arming signal would protect it.


Other measured results

The rearm heuristic roughly doubles the deadline per event-loop stall, unbounded. Driving the compiled module with the constants replaced by 9s (drift guard untouched):

Event-loop block straddling the deadline Actual abort
none 9.02s
500ms (drift < 1s) 9.01s
2500ms 19.52s
6000ms 23.02s
12000ms 26.02s

rearm re-schedules a full fresh window, not the remaining time, and the rearm count is unbounded. At shipped constants that is "15 minutes, plus 15 more for every event-loop stall over one second" — in a CLI that spawns subprocesses and re-renders Ink, not exotic. Also, since expectedAt is measured with performance.now() — the same monotonic clock driving the timer — a genuine host suspend produces no measured drift and never triggers the rearm at all, so the "host suspend" half of the design-doc claim does not hold. Banking the remainder, or bounding the rearm count, would be cheap.

Prettier. Confirmed: 4 files fail prettier --check on the PR head and all 4 are clean at the merge base. The failing hunks are exactly the union-type and &&-indent reformats, all unrelated to the change. Note the PR body says "Static diff review and formatting only" — the formatting went the other way.

Typecheck / tests. tsc --noEmit -p packages/core is clean. The full packages/core/src/agents/** + tools/agent/** suite is green: 2162 passed, 6 skipped, 51 files. So nothing regresses — but v8 coverage of agent-progress-watchdog.ts under that suite is 57% statements purely incidentally (attach/dispose on background paths), with zero assertions on any watchdog behavior. No abort path is executed by any test. The sibling precedent workflow-stall.ts ships 306 lines with 362 lines of tests and takes its window as a parameter; this ships 202 lines, hardcoded, with none.

Confirmed as designed, but undocumented. Both pauses are unbounded — I drove a parked approval for a simulated 60 minutes with no timer running at all. That is probably right (an outer wall-clock limit covers it), but the design doc should say so rather than leave it looking like the original symptom relocated.


Recommendation

The problem is real, the architecture is sound, and the plumbing is genuinely careful — the typed abort reason, the .finally(disposeWatchdog) on both fork branches, and the AgentHeadless early return still running its finally are all correct. I'd like to merge this. Before that:

  1. Blocking — handle transport backoff. Killing a throttled-but-healthy Agent permanently is worse than the wedge this fixes.
  2. Blocking — add tests. workflow-stall.test.ts already establishes the fake-timer harness; the 429 case above is a five-line test.
  3. Drop the Prettier hunks.
  4. Non-blocking: bank the remainder on rearm; comment the agent-core.ts:2076 dependency; document the unbounded pauses; merge onToolProgress/onToolHeartbeat.
  5. Retargeting the base at main would switch ordinary CI on — right now test, lint_and_static, build, typecheck and format never ran for this head SHA.
Harness details & limitations
  • Deadlines shrunk to 12s/8s in the built bundle (constants only). Rearm ladder used a 9s copy of the compiled module with the 1000ms drift guard untouched.
  • Merge-base arm = attachAgentProgressWatchdog neutered to () => {} in the same bundle.
  • tools.shell.heartbeatIntervalMs: 2000 for the shell-renewal run so heartbeats fall inside the shrunk tool deadline; at shipped values the real 10s default sits far inside 10 minutes.
  • One unrelated environment workaround: this box cannot build web-shell (missing tailwindcss/theme.css in node_modules), so web-templates' generated export-transcript template is a stub. Identical on both arms and untouched by this PR.
  • Verified at the state-machine level only, not end-to-end: the restored-Agent (background-agent-resume.ts) path, and the Monitor external-input pause.
中文版

PR #11270 真机验证报告(维护者复核)

我没有只读 diff,而是在本地搭了真实环境,用真实后台 Agent 端到端跑了这个 PR。验证提交:25df3993ab205790bbb73cd5d71a42d42820edd8

结论:机制本身可用、接线正确,但有一个场景是回归,我认为阻塞合并 —— 一个健康的后台 Agent 只要处在普通的 HTTP 429 传输退避中,就会被杀掉并永久结算为 failed,且按设计不会重试。同时我推翻了静态审查的 Finding 2:不产生流式输出的工具确实拿到了自己的逐工具期限。

验证环境

用 PR head 构建的真实 dist/cli.js,在 tmux 里跑真实 TUI,隔离 HOME,配脚本化的 OpenAI 兼容服务器,以及一个工具永不返回的真实 stdio MCP server。为了让期限在秒级可观测,我只改了构建产物里的两个常量:15 * 6e4 → 12e310 * 6e4 → 8e3,逻辑一行未动。

merge-base 对照臂是同一个 bundle,把 attachAgentProgressWatchdog 替换为 () => {}。这是一次精确的行为回退:本 PR 其余所有 hunk 都以 getAgentProgressTimeout(signal) 返回值为前提,而只有 watchdog 触发中止时它才会有值。

另有一个插桩臂,把日志订阅到 watchdog 监听的全部 11 个事件上,直接捕获状态机的输入,而不是靠推断。

验证通过的部分

场景 PR 臂 merge-base 臂
模型卡死(服务器挂住请求不响应) failed @ 12.04slastError: "Background agent made no model/control progress for 12000ms." 60s 时仍 running,请求仍被挂住
不产生流式输出的 MCP 工具卡死 failed @ 8.36slastError: "…tool \"mcp__hangsrv__hang_forever\" made no progress for 8000ms."
静默 30s 的 shell 命令,心跳 2s 正常完成 @ 30.44s

整条链路成立:watchdog → 在 turn controller 上抛 AgentProgressTimeoutErrorTIMEOUT terminateMode → registry/sidecar 结算 failed → TUI 恰好一次通知,无重试。中止也确实传播到了在途 HTTP 请求(假服务器记录到客户端在连接建立 11.9s 后主动断开)。首次启动和驻留 Agent 后续 turn 两条包装路径都被覆盖。

run_shell_command 那一行很关键:一个比工具期限长 3.75 倍的静默命令跑到了正常结束,靠 14 个 tool_progress 心跳续期。PR 描述里的续期主张是真的,不存在误杀正常长时间静默命令的问题。

阻塞项:健康的 Agent 在传输退避中被杀

让子 agent 的 provider 返回 HTTP 429Retry-After: 45 —— 一次普通的、完全可恢复的限流。

+0.00s  ATTACH
+0.04s  start / round_start        -> 模型期限启动
+0.21s  <429 Retry-After: 45>      传输退避开始
        ... 整个退避期间没有任何一个 watchdog 事件 ...
+45.40s 结算: failed
  • PR 臂: failedlastError: "Background agent made no model/control progress for 12000ms."
  • merge-base 臂: 60s 时仍 running已发出 2 次重试,完全按设计在恢复。

agent-core.ts 处理 retry 流事件时只重置每次尝试的状态,不向 AgentEventEmitter 发任何东西,所以整个退避对 watchdog 完全不可见。按发布常量算这不是边角场景:llm-chat.tsRATE_LIMIT_RETRY_OPTIONS{maxRetries: 10, initialDelayMs: 60000, maxDelayMs: 300000},到第 5 次(共 10 次)重试时累计休眠已达 1020s,超过 900s 期限而后面还有五次;retry.ts 把单次 Retry-After 等待上限设为 PERSISTENT_CAP_MS = 6 小时。后台 Agent 恰恰是最容易撞上配额窗口的长命对象,而这个 watchdog 又刻意不重试,于是一次限流抖动就变成永久死亡的 Agent,通知里还写着它"没有进展"——而它当时正按设计重试。

对静态审查机制描述的一处订正。 该审查称 delay() 会响应 abort 信号,因此 watchdog 会"主动打断"退避。实际不是这样。abort 在期限处确实触发了,但休眠没有被打断——结算时间精确跟随 Retry-AfterRetry-After: 30 → 30.4s 结算,Retry-After: 45 → 45.4s 结算(两次独立运行)。重试循环要等完整休眠结束后才观察到 abort。结果相同,时序不同。

修法二选一:在 retry 分支发出事件;或在退避进行中暂停模型期限。

已推翻:Finding 2(静默执行中的工具被算到模型期限)

静态审查认为只有会流式输出的工具才会进入 state: 'executing',因此慢速 MCP 调用会被算到 15 分钟模型期限上、并以"没有模型/控制进度"为由中止。我读代码时也有同样的假设。真机运行推翻了它。

+0.37s  tool_call             callId=call_hang_1  name=mcp__hangsrv__hang_forever
+0.37s  tool_output_update    callId=call_hang_1     <- execution-start 事件
+8.37s  tool_result           callId=call_hang_1
=> lastError: Background agent tool "mcp__hangsrv__hang_forever" made no progress for 8000ms.

agent-core.ts:2070-2084 会在 → executing 状态转换时,为每一个工具(无论是否流式)发出一个 TOOL_OUTPUT_UPDATE(它存在的目的是让 agent 视图能在首个输出之前就提供 Ctrl+F 并启动计时)。这正是该审查认为事件流中缺失的"开始执行"信号。我的 MCP server 完全不发 progress 通知,依然拿到了自己的逐工具期限,phase 是 tool、工具名也正确。同一个 emit 在 awaiting_approval → executing 时也会触发,所以"审批通过后工具失去监控"的变体同样不成立。

值得作为脆弱点(而非 bug)记录:工具期限是被一个用途为 UI 的事件启动的,代码和 docs/design/background-agent-progress-watchdog.md 都没写这一点。如果哪天那个 emit 变成有条件的,所有工具都会静默失去期限。建议在 onToolProgress 处加一行注释,点名 agent-core.ts:2076 是承重的启动信号。

其他实测结果

rearm 启发式每遇一次事件循环卡顿就大致翻倍期限,且无上界。 用常量替换为 9s 的编译模块驱动(drift 判据保持原样):

跨越期限的事件循环阻塞 实际中止时刻
9.02s
500ms(drift < 1s) 9.01s
2500ms 19.52s
6000ms 23.02s
12000ms 26.02s

rearm 重新排的是完整的新窗口而不是剩余时间,而且 rearm 次数无上界。按发布常量就是"15 分钟,再加上每次超过 1 秒的事件循环卡顿各追加 15 分钟"——在一个会派生子进程、会重绘 Ink 的 CLI 里,这并不罕见。另外,由于 expectedAt 用的是 performance.now()(与驱动定时器同一个单调时钟),真正的主机休眠产生不了可测量的 drift,也就根本不会触发 rearm——设计文档里"主机休眠"那一半主张不成立。改成结转剩余时间,或给 rearm 次数设上界,成本都很低。

Prettier。 已确认:PR head 上有 4 个文件 prettier --check 不通过,而这 4 个文件在 merge base 上全部干净。不通过的 hunk 正是那些联合类型换行和 && 缩进的重排版,与本次改动无关。注意 PR 正文写的是"只做了静态 diff 复核和格式化"——格式化的方向反了。

Typecheck / 测试。 tsc --noEmit -p packages/core 干净。packages/core/src/agents/**tools/agent/** 全套测试全绿:2162 通过、6 跳过、51 个文件。所以没有回归——但在该套件下 agent-progress-watchdog.ts 的 v8 语句覆盖率 57% 完全是附带产生的(后台路径上的 attach/dispose),对 watchdog 的任何行为零断言,没有任何测试执行过中止路径。同门先例 workflow-stall.ts 是 306 行代码配 362 行测试,而且窗口是参数化的;本 PR 是 202 行、硬编码、无测试。

确认为设计如此,但缺文档。 两个暂停都是无界的——我让一个停在审批上的工具跑了模拟 60 分钟,期间没有任何定时器在运行。这大概率是对的(外层墙钟限制兜底),但设计文档应当写明,否则读起来像是原来的症状换了个地方继续存在。

建议

问题真实,架构合理,管线处理确实细致——带类型的 abort reason、fork 与非 fork 两条分支上的 .finally(disposeWatchdog)AgentHeadless 提前 return 仍会执行 finally,这些都是对的。我希望这个 PR 能合入。合入前请处理:

  1. 阻塞 —— 处理传输退避。把一个被限流但健康的 Agent 永久杀掉,比它要修的卡死问题更糟。
  2. 阻塞 —— 补测试。workflow-stall.test.ts 已经把 fake-timer harness 建好了;上面那个 429 场景就是五行测试。
  3. 去掉 Prettier 重排版的 hunk。
  4. 非阻塞:rearm 改为结转剩余时间;给 agent-core.ts:2076 这个依赖加注释;把无界暂停写进文档;合并 onToolProgress / onToolHeartbeat
  5. 把 base 改回 main 就能直接让常规 CI 生效——目前这个 head SHA 上 testlint_and_static、build、typecheck、format 一个都没跑过。

验证环境的边界: 期限在构建产物里缩短为 12s/8s(仅常量);rearm 阶梯用的是常量替换为 9s、drift 判据未动的编译模块副本;merge-base 臂是把 attachAgentProgressWatchdog 置为 () => {};shell 续期那一轮设了 tools.shell.heartbeatIntervalMs: 2000,以便心跳落在缩短后的工具期限内(发布值下真实的 10s 默认远小于 10 分钟)。一处与本 PR 无关的环境绕行:本机无法构建 web-shellnode_modulestailwindcss/theme.css),因此 web-templates 的导出模板用了 stub,两臂完全一致。仅在状态机层验证、未做端到端的部分:恢复 Agent(background-agent-resume.ts)路径,以及 Monitor 外部输入暂停。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Addressed the two current-head correctness findings in 9d95f912c835. Transport retry events now suspend the model/control deadline until observable model activity resumes. Tool deadlines now start from the scheduler's actual executing transition, including a fresh transition after approval, so a silent running tool is no longer charged to the model deadline. The span classification now exempts only watchdog aborts rather than every pre-existing TIMEOUT mode, and the unrelated formatting hunks were removed. No tests were added and no local test, build, typecheck, or CI command was run.

@yiliang114
yiliang114 changed the base branch from codex/issue-11118-session-hold to main September 7, 2026 07:26
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

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

No response changes against the PR base across 12 scenario(s).

Qwen Code · serve A/B

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (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: 52 passed · 0 failed · 52 total

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

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

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

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

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

Verification report

PR #11270 — deep verification

Verdict: findings — 52 scripted assertions executed, 52 pass / 0 fail. The central claim is load-bearing and proven against a base control at real wall-clock deadlines. Five items are worth a reviewer's attention: one measured design consequence the PR's own tests pin deliberately, one notification that the interactive TUI drops, an escalation half that no test pins (two user-visible guards are deletable with everything green), a rewritten doc comment that no longer matches its function, and one documented-but-unbounded admission tradeoff.

Verified head OID: 6bc80c0df4662088f9e4da666a0af1778760dd9b (git rev-parse HEAD^2)
Base control: cb94a33f7152f1d3c74829dfc9556378b0843fd3 (HEAD^1, merge-ref checkout)

中文摘要

结论:findings —— 共执行 52 条脚本化断言,52 通过 / 0 失败。核心主张成立,并且在真实墙钟期限下与 base 对照组完成了 A/B 验证。有 5 点值得 reviewer 关注:一个被 PR 自身测试刻意固定的设计后果(已测量)、交互式 TUI 丢弃的一条通知、整个 escalation 链路无任何测试固定(两个用户可见的 guard 删掉后全部测试仍然绿)、一段被改写后与实现不符的文档注释,以及一个已在设计文档中说明但无上界的 admission 取舍。

  • A/B 结论:head 侧在 900,681 ms 处以 AgentProgressTimeoutError(phase='model/control') 中止,5 秒后升级,registry 一次性结算为 failedrecordOnly=true,物理槽位保留;base 侧在 14 m / 15 m20 s / 16 m / 30 m40 s 四个探针上始终 aborted=falsestatus='running'、通知数 0。见下方 “Central claim and A/B” 表与 01-ab-head-cells-settle-as-timeout.png02-ab-base-control-cell-never-settles.png
  • findings:F1 嵌套 external-input park 会取消所有期限且不做 Monitor 交叉校验(已测量;候选修复会让 PR 自己的 2 个测试变红,因此属于设计取舍而非疏漏);F2 交互式 TUI 直接丢弃 recordOnly 通知,与设计文档“记录并展示”的表述不符;F3 escalation 半侧完全无测试固定,hasRunningTasks()describeBlockingBackgroundWork() 两处 retained-slot guard 变异后存活;F4 isChannelLive() 的注释被改写成“active 且可接收新工作”,但实现对 draining generation 仍返回 true;F5 两个 draining generation 时新工作被 503 拒绝,而对忽略中止的 Agent 来说“直到其中一个退出”可能永远不发生(设计文档已说明)。
  • 未覆盖范围:逐 commit 归因(depth-2 checkout,rev-list 只返回 1 个而元数据有 26 个);测试计划第 5 步(restored / resident continuation);bridge 侧 generation draining 与 recycle 路由未实驱动;未做端到端 CLI + 真实假模型服务器运行;未跑 typecheck / lint / format。

Scope chosen

Central claim — an ordinary background Agent turn whose model/control path stops producing events now aborts once at a fixed deadline and settles as TIMEOUT → registry failed, where the base build leaves it registered as running forever.

Secondary claims — (S1) each executing tool owns an independent 10-minute deadline; queued tools own none; approval and nested external-input waits suspend it. (S2) a run that ignores the cooperative abort escalates after a 5-second grace to failUnresponsive: one failed settlement, one recordOnly notification, physical slot retained, released only when the run's promise finally settles.

Out of scope by choice (listed under Not covered): the bridge-side runtime-generation machinery, the resume/resident paths, typecheck and lint.

Central claim and A/B

Both arms load the real compiled dist/ of their own worktree and run on real timers — no fake clock, no stub of the unit under test. The only synthetic element is the model: a wedged turn is represented by an event stream that stops, which is the failure the PR exists to bound. Arm identity is asserted inside each run rather than assumed: the base arm's watchdog import fails with ERR_MODULE_NOT_FOUND and its registry has no failUnresponsive, so the control cannot silently be measuring head code (ARM-IDENTITY, both arms).

Cells printed as they ran: 01-ab-head-cells-settle-as-timeout.png (head, six cells) and 02-ab-base-control-cell-never-settles.png (base control).

cell scenario driven oracle head base
model-stall START, ROUND_START, then silence abort reason + registry state abort at +900,681 ms, phase=model/control, timeoutMs=900000; escalation notification at +905,686 ms (5,005 ms later) with recordOnly=true, status=failed; slot retained; released on demand not aborted at 840,704 / 920,723 / 960,752 / 1,840,760 ms; status='running'; 0 notifications
tool-stall one tool reported executing, then silent abort phase + tool name abort at +600,689 ms, phase=tool, toolName=run_shell_command n/a (no mechanism)
tool-renew two executing tools; t1 read_file renewed at 5 m and 9 m, t2 run_shell_command never which tool fires abort at +600,696 ms on t2 (the un-renewed tool) — renewal is per-tool, not global n/a
queued-tool TOOL_CALL only, never executing absence of a tool deadline no abort at 10 m 30 s; abort at 15 m phase=model/control n/a
approval-park executing → TOOL_WAITING_APPROVAL tool deadline suspended, model deadline not no abort at 10 m 30 s; abort at 15 m phase=model/control n/a
nested-input-park executing → TOOL_PROGRESS{waitingForExternalInput:true}, with isWaitingForExternalInput() returning false any deadline at all no abort at 16 m; entry still running (see F1) n/a

Timer fidelity: the harness's own scenario clock started at +654 ms (first probe scheduled for 1,000 ms recorded 1,654 ms), so the true lateness of the 15-minute timer was ≈ 27 ms and of the 10-minute timer ≈ 35 ms — well inside the watchdog's 1-second drift-rearm tolerance. The rearm path therefore never fired; MS-15 confirms the deadline was still settled at 30 m 40 s, so no cell was lost to a rearm.

Assertion totals per run: head full 31/31, base full 6/6, head smoke 12/12, base smoke 3/3 → 52 pass, 0 fail (assertions.json). Base-cell reds are encoded as expectations that the control does not settle, so they count as passes.

Escalation chain, driven through the real BackgroundTaskRegistry (smoke cells SM-4SM-12, and MS-7MS-14 on the real 15-minute path): failed + retainsPhysicalSlot + exactly one notification with recordOnly=true + timeout message persisted on the entry + capacity still consumed with the cap at 1 + a second escalation tick is idempotent + releaseRetainedPhysicalSlot() frees capacity.

Corrections to the PR description

These are corrections to the description, not requests to change code.

  1. Test-plan step 3 is true only inside the model deadline. "Park a background tool on approval for longer than its deadline, then approve it. Confirm no timeout occurs while parked" — measured AP-1 confirms no timeout at 10 m 30 s, but AP-2/AP-3 show the parked tool is aborted at 15 m with phase=model/control. The design doc states this correctly ("The relevant tool deadline is replaced by the model deadline while user approval is pending"); the test-plan wording does not, and a reviewer following it literally would wait forever for a timeout that arrives at 15 m.
  2. "persisted and notified once as failed" / "recorded and displayed" is not what the interactive TUI does. docs/design/background-agent-runtime-generations.md says the terminal notification "is recorded and displayed without starting another model turn". That holds on the daemon Session path and on the headless path, but the interactive TUI drops it outright — see F2.
  3. Per-commit attribution was not possible, and the naive check hides it. git rev-list HEAD^1..HEAD^2 returns 1 commit at this depth-2 checkout while the metadata snapshot lists 26; the bare count looks plausible rather than erroring. Only the aggregate HEAD^1..HEAD diff was verified.

Findings

F1 — a nested external-input park suspends every deadline without the Monitor cross-check the top-level path applies (Suggestion)

onToolHeartbeat's waitingForExternalInput branch calls clearModel() and returns without arming anything, so the turn has no deadline — model or tool. The top-level equivalent (onRoundEnd) is stricter: it suspends only when event.waitingForExternalInput === true && !roundHadToolCalls && isWaitingForExternalInput(), i.e. it asks the Monitor registry whether a Monitor is actually running for this owner. The nested branch never asks.

Measured (NP-1, NP-2, cell nested-input-park): with isWaitingForExternalInput() wired to return false, a single TOOL_PROGRESS{waitingForExternalInput:true} event left the entry un-aborted and status='running' at 16 minutes — past both deadlines. Reproduce:

node tmp/pr11270-verify-20260909-191050/harness-watchdog-ab.mjs \
  --tree /__w/qwen-code/qwen-code --arm head --mode full --only nested-input-park

What this is not. I did not demonstrate an unbounded end-to-end wedge, and I do not believe this is an oversight. Applying the obvious candidate fix — gating the nested park on isWaitingForExternalInput() too — turns 2 of the PR's own 6 tests red (2 failed | 4 passed, both AssertionError: expected 'tool' to be undefined): keeps a nested external-input wait free of any deadline until progress resumes and keeps the model deadline suspended while a nested input wait outlives sibling tools both construct the park with the callback returning false and assert no deadline. The behaviour is deliberate and pinned. The exposure is also indirectly bounded in the common case: the nested child has its own watchdog whose top-level path does cross-check the Monitor registry, so a child whose Monitor is gone times out at 15 m, settles, and its tool result un-parks the parent.

What remains for the author to decide is the asymmetry itself: the nested path trusts a flag forwarded from a child's display state, while the top-level path verifies it against the Monitor registry. A nested chain in which each level parks on the next, or a legitimately long-running Monitor, has no deadline at any level. Since closing it means rewriting two of this PR's tests, I am reporting it as a design question rather than shipping a measured fix.

F2 — the interactive TUI drops the unresponsive-Agent notification entirely (Suggestion)

packages/cli/src/ui/hooks/use-llm-stream.ts:6298 is the whole handler:

registry.setNotificationCallback((displayText, modelText, meta) => {
  if (meta?.recordOnly) return;

No display, no history entry. The other two consumers do record it: Session.ts:9554 routes to #recordUnresponsiveAgentNotification (persist at 9763, display at 9960, end_turn at 9968), and nonInteractiveCli.ts:1526-1527 pushes the item with recordOnly and still emits it to the SDK, filtering it only out of the model batch at 2802. So the interactive TUI is the one surface where a background Agent killed for being unresponsive produces no proactive signal at all.

The consequence compounds, because the same PR makes the retained slot block user commands: hasRunningTasks() counts retainsPhysicalSlot, so /clear, /branch, /resume and session-switch all refuse, and the only place the reason surfaces is the refusal message's — still stopping line. A user who never tries one of those commands is never told. This is static (code-path) evidence — I did not drive the React hook.

F3 — the escalation half of the PR is unpinned; two user-visible guards are deletable with everything green (Suggestion)

Census over all *.test.ts in packages/: expect(onUnresponsive0 hits; releaseRetainedPhysicalSlot, sessionRuntimeRecycle, requestRuntimeRecycle, BridgeRuntimeRecyclingError0 hits each. attachAgentProgressWatchdog appears in exactly one test file, which declares onUnresponsive = vi.fn() and never asserts on it.

Mutation matrix (all rows run against agent-progress-watchdog.test.ts unless named; 03-mutation-matrix-and-guards.png):

row mutation result classification
M0 none (green control) 6 passed suite is green
M8 positive control: tool deadline 10 m → 5 m KILLED 1/6, expected 'tool' to be undefined harness can make this file fail
M6a armModel ignores parkedOnInput KILLED 1/6, expected 'model/control' to be undefined pinned
M6c M6a and M6b reverted together KILLED 1/6 (re-run) the guard set is load-bearing
M6b nested park does not clearModel() SURVIVED redundant defence — M6a alone and the M6c combination are both killed, so this hunk cannot be observed alone
M1 never arm the escalation timer SURVIVED coverage gap — behaviour is real (MS-7 fired at +5,005 ms on the live 15-minute path)
M4 queued tool starts its deadline at TOOL_CALL SURVIVED coverage gap — behaviour is real (QT-1)
M2 drop the host-suspend drift guard SURVIVED coverage gap — measured lateness was 27–35 ms, so the guard never fired in my cells
M5 remove timer.unref() SURVIVED coverage gapprobe-unref-exit.mjs shows the process exits in 5 ms with deadlines armed
M3 drop the 6-hour retry-extension clamp SURVIVED coverage gap (no cell drove MODEL_RETRY)
M7 dispose() no longer clears tool timers SURVIVED coverage gap
M9 approval-branch control not run — anchor mismatch reported as not run, not as a survivor
G1 hasRunningTasks(): drop || entry.retainsPhysicalSlot SURVIVED 150/150 coverage gap on the clause that blocks /clear, /branch, /resume, session-switch
G2 describeBlockingBackgroundWork(): drop the retained-slot branch SURVIVED 26/26 coverage gap on the — still stopping label
V1 failUnresponsive(): drop entry.retainsPhysicalSlot = true KILLED 1/150, expected undefined to be true the PR's one new registry test is non-vacuous

One row needs a caveat: M6c first reported SURVIVED while its own component M6a was KILLED, which is internally inconsistent. I re-ran M6a/M6b/M6c with the mutation verified on disk by sha256 before each run (appliedOnDisk=true, distinct hashes); M6c is KILLED on re-run, so the first result was a stale-transform artifact of my harness, not a property of the tests. The re-run values are the ones in the table.

G1 and G2 are the rows I would act on: they are the only tests standing between this PR's user-visible blocking behaviour and a silent regression, and both are deletable today with a fully green suite.

F4 — isChannelLive()'s rewritten doc now describes a different function (Nit)

packages/acp-bridge/src/bridgeTypes.ts:2500 was rewritten by this PR to read "Whether an ACP channel is active and can accept fresh workspace work." The implementation does not do that: bridge.ts:6248 liveChannelInfo() returns any non-dying channel, so a draining generation reports live, and isChannelLive() (bridge.ts:9618) forwards it. Admission deliberately uses the other predicate the PR added — admissibleChannelInfo() (bridge.ts:6251, state === 'active') — and spawnOrAttach throws when ci.state !== 'active'. The PR itself moved generateWorkspaceAgent and runtime MCP add/remove onto admissibleChannelInfo(), so the distinction is intentional; the comment landed on the wrong function.

Observable effect: acpChannelLive / channelLive (workspace-service/index.ts:474, 553, 560, 581, routes/health.ts:92, daemon-status.ts:675, 712) report a live channel while fresh work is refused with 503 runtime_recycling. The previous comment ("spawned and not dying") matched the behaviour and was removed.

F5 — two draining generations refuse fresh work until one exits, which for an abort-ignoring Agent may be never (Note — documented tradeoff)

ensureChannel (bridge.ts:4605-4614) throws BridgeRuntimeRecyclingError when two or more non-dying generations exist, and retireChannelAfterSessionsDrain only kills a generation whose sessions have drained. A second recycle in the same workspace therefore leaves no active generation, and fresh work is refused 503 runtime_recycling (retryable) indefinitely — while the condition that triggered recycling in the first place is a run that ignores abort. This is explicitly documented in docs/design/background-agent-runtime-generations.md ("If both are draining, admission fails with 503 runtime_recycling until one exits") and is loud on stderr, so I report it as a bounded tradeoff a reviewer is agreeing to, not as a defect. Not measured: I did not drive a real daemon through two successive recycles.

Observation (not a finding)

Watchdog timers are unref()'d, so they cannot hold a process open: probe-unref-exit.mjs armed both deadlines and the process exited in 5 ms, exit code 0, aborted: false. In the daemon and the interactive TUI other handles keep the loop alive, and in a headless run an empty loop means the process ends rather than wedges — so this reads as benign, but it is unpinned (M5 survived) and my own full runs needed a ref'd heartbeat to reach their deadlines.

Not covered

  • Per-commit attribution. Depth-2 checkout: only the merge commit, HEAD^1 and HEAD^2 exist locally. git rev-list HEAD^1..HEAD^2 returns 1 while the snapshot lists 26 commits. Aggregate diff only.
  • Test-plan step 5 (restored Agent and resident continuation; workflow dispatch and foreground Agents keeping their existing policy). Not driven. Static reading shows background-agent-resume.ts attaches the watchdog with the same four arguments and the same finally disposal; the foreground/workflow exclusion was not verified at all.
  • Test-plan step 6's bridge half. Slot retention, single recordOnly notification and "without starting a model turn" were measured at the registry level; generation draining, the qwen/control/session/runtime/recycle route, its ownsSession/reason validation, and the 503 mapping were not driven — no daemon harness. bridge.test.ts (920 tests) and bridgeClient.test.ts are green, but I have no liveness proof for that gate (no planted violation), so I cite it as executed-and-green only.
  • No end-to-end CLI run. The A/B drove the compiled watchdog plus the real registry against a synthetic event stream. It did not execute AgentToolInvocation, AgentCore's reasoning loop, or a real model transport. Consequently the abort → AgentTerminateMode.TIMEOUT mapping in agent-core.ts/agent-headless.ts/agent.ts and the "never enters the workflow retry loop" claim are verified by reading, not by execution. This reproduces the shape of the wedge (an event stream that stops), not a model-side stall that produces one.
  • The 6-hour retry-extension clamp (MAX_RETRY_DEADLINE_EXTENSION_MS) and the MODEL_RETRY path: no cell drove them; M3 survived.
  • The drift-rearm path never executed — measured lateness (27–35 ms) stayed far under its 1 s tolerance, so I have no evidence about its behaviour under host suspend.
  • typecheck, lint, format, repo-wide test suite — not run. Gates were limited to the affected files.
  • No measured fix for F1, for the reason given in F1: closing it requires rewriting two of the PR's own tests, which is the author's call.

Targeted gates

gate command result
core (changed files) cd packages/core && npx vitest run src/agents/runtime/agent-progress-watchdog.test.ts src/agents/background-tasks.test.ts src/agents/runtime/agent-core.test.ts src/agents/runtime/agent-headless.test.ts src/agents/background-agent-resume.test.ts src/tools/agent/agent.test.ts 6 files, 605 passed
acp-bridge cd packages/acp-bridge && npx vitest run src/bridge.test.ts src/bridgeClient.test.ts 2 files, 1052 passed
cli (changed files) cd packages/cli && npx vitest run src/ui/utils/backgroundWorkUtils.test.ts src/nonInteractiveCli.test.ts 2 files, 188 passed, 1 skipped

Gate liveness: proven for agent-progress-watchdog.test.ts (M8 killed 1 test) and background-tasks.test.ts (V1 killed 1 test). For backgroundWorkUtils.test.ts, G2 is itself the liveness probe and it survived — the file is green but does not pin the branch this PR added. No liveness proof for the bridge gate.

Methodology

CI merge-ref checkout of refs/pull/11270/merge in node:22-bookworm, 64 cores, load ≈ 35 at start; npm ci and npm run build were already complete at HEAD. The base arm is a scratch worktree at HEAD^1 under tmp/base-tree, whose packages/{core,cli,acp-bridge,web-templates,sdk-typescript}/node_modules were symlinked to the head tree's — a clean control because git diff --name-only HEAD^1..HEAD shows this PR touches no package.json, package-lock.json or pnpm-lock.yaml. Base core rebuilt in place; the build exited 1 on a single pre-existing TS7016 for @lydell/node-pty in src/services/shellExecutionService.ts — a file this PR does not touch, failing on a third-party package's type layout — and still emitted the JS the A/B consumed (dist/src/agents/background-tasks.js), with zero errors in any agents/ file.

Because node_modules/@qwen-code/qwen-code-core is a symlink into the head tree (readlink -f/__w/qwen-code/qwen-code/packages/core), a naive base harness would have loaded head code and passed both cells. The harness therefore imports each arm's compiled modules by absolute path inside that arm's own worktree, and asserts arm identity from the loaded code itself: base reports ERR_MODULE_NOT_FOUND for agent-progress-watchdog.js and has no failUnresponsive on its registry, head has both. packages/core has no workspace dependencies, so nothing else crosses the boundary.

Every cell ran the real compiled watchdog, a real AgentEventEmitter, a real AbortController and a real BackgroundTaskRegistry (capacity pinned to 1 so slot occupancy is observable through the public canStartBackgroundAgent()), with the watchdog wired exactly as packages/core/src/tools/agent/agent.ts wires it. Timers were real: the six head cells and the base control ran concurrently in two processes over a shared 30 m 40 s window, each with a ref'd 30 s heartbeat because the watchdog's own timers are unref()'d. Mutations were applied to source, run, and restored byte-identically; git status --porcelain is clean apart from tmp/ after every batch. Raw logs, harness scripts and per-run JSON are in this directory; harness-watchdog-ab.mjs, mutation-matrix.mjs, mutation-rerun.mjs, guard-mutations.mjs and probe-unref-exit.mjs are rerunnable as-is.

Flakiness gate log

rounds=5 files=3 skipped=0
file packages/acp-bridge/src/bridge.test.ts: (cd packages/acp-bridge) npx --no-install vitest run ./src/bridge.test.ts
file packages/core/src/agents/background-tasks.test.ts: (cd packages/core) npx --no-install vitest run ./src/agents/background-tasks.test.ts
file packages/core/src/agents/runtime/agent-progress-watchdog.test.ts: (cd packages/core) npx --no-install vitest run ./src/agents/runtime/agent-progress-watchdog.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/acp-bridge/src/bridge.test.ts: PPPPP
  packages/core/src/agents/background-tasks.test.ts: PPPPP
  packages/core/src/agents/runtime/agent-progress-watchdog.test.ts: PPPPP

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

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 1 · packages/core/src/agents/background-tasks.test.ts: P (exit 0)
round 1 · packages/core/src/agents/runtime/agent-progress-watchdog.test.ts: P (exit 0)
round 2 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 2 · packages/core/src/agents/background-tasks.test.ts: P (exit 0)
round 2 · packages/core/src/agents/runtime/agent-progress-watchdog.test.ts: P (exit 0)
round 3 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 3 · packages/core/src/agents/background-tasks.test.ts: P (exit 0)
round 3 · packages/core/src/agents/runtime/agent-progress-watchdog.test.ts: P (exit 0)
round 4 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 4 · packages/core/src/agents/background-tasks.test.ts: P (exit 0)
round 4 · packages/core/src/agents/runtime/agent-progress-watchdog.test.ts: P (exit 0)
round 5 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 5 · packages/core/src/agents/background-tasks.test.ts: P (exit 0)
round 5 · packages/core/src/agents/runtime/agent-progress-watchdog.test.ts: P (exit 0)

Evidence images

01-ab-head-cells-settle-as-timeout

02-ab-base-control-cell-never-settles

03-mutation-matrix-and-guards

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

⏸️ Deferring to @wenshao — not approving and not requesting changes.

The watchdog half of this PR is in good shape: both correctness paths from the last triage pass are closed at the wiring level, the round-2 MODEL_RETRY regression is fixed by bounded re-arm, tests now cover both deadlines and every pause condition, the base is main, and CI is green at this head.

The one thing I cannot settle statically is in the runtime-generation half: ensureChannel's admission parameter reaches only a log string, so the fresh-work ceiling also blocks recycle recovery — which background-agent-runtime-generations.md says it should not. Since requestRuntimeRecycleForSession condemns the owner generation before asking for a replacement, a second recycle issued while a previous generation is still draining throws and leaves the workspace with no active generation and a 503 on every fresh-work path. I could not find a bound on when a draining generation exits.

The open question is a runtime one, and I am not permitted to execute this PR's code: can a second recycle land while a previous generation is still draining with pinned sessions, and if it does, does the workspace recover? Full detail and the three non-blocking notes are in the Stage 2 comment above. The @qwen-code /verify run already in flight on this head would settle it if pointed at the two-recycle sequence.

Assigned to you so this does not sit invisible in the thread.

中文说明

⏸️ 转交给 @wenshao —— 不批准,也不请求修改。

这个 PR 的 watchdog 那一半状态不错:上次 triage 提的两条正确性路径都在接线层面关闭了,第二轮的 MODEL_RETRY 回归用有界重启修好了,测试现在覆盖了两个期限和每一个暂停条件,base 是 main,CI 在这个 head 上是绿的。

我静态无法定论的一点在 runtime generation 那一半:ensureChanneladmission 参数只到达一个日志字符串,所以新工作上限也阻挡了 recycle recovery——而 background-agent-runtime-generations.md 说它不该阻挡。由于 requestRuntimeRecycleForSession 是先把 owner generation 判死、再请求替代者,所以在上一个 generation 仍在 draining 时发出的第二次 recycle 会抛错,并让工作区停在"没有 active generation、所有新工作路径都 503"的状态。我找不到 draining generation 何时退出有任何上限。

未决的问题是一个运行时问题,而我不被允许执行这个 PR 的代码:第二次 recycle 是否可能在上一个 generation 仍带着固定 session 处于 draining 时落地,如果落地了,工作区能否恢复?完整细节和三条不阻塞的说明在上面的 Stage 2 评论里。当前 head 上已经在跑的 @qwen-code /verify,如果指向"两次 recycle"这个序列,就能定这件事。

已经把你设为 assignee,以免这件事在讨论串里无人看见。

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

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

@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 6bc80c0df4662088f9e4da666a0af1778760dd9b — 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.

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

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

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

  • D6-10 isChannelLive() doc promises admission, impl reports… — packages/acp-bridge/src/bridgeTypes.ts:2500 — already reported (round-5 deferral list (bridgeTypes.ts:2497))
  • D6-11 New public requestRuntimeRecycle member has no caller — packages/acp-bridge/src/bridge.ts:9665 — already reported (comment 3950282825 (R1-11) and the round-5 deferral list)
  • D6-12 notified early-return skips the physical slot retention — packages/core/src/agents/background-tasks.ts:920 — already reported (round-5 deferral list (background-tasks.ts:921))
  • D6-14 New error class absent from the metrics allowlist and the… — packages/cli/src/serve/acp-http/dispatch.ts:914 — already reported (round-5 deferral list (bridgeErrors.ts:676))
  • D6-15 settled TOOL_PROGRESS re-fires with no transition guard — packages/core/src/agents/runtime/agent-core.ts:2069 — already reported (round-5 deferral list (agent-core.ts:2058))
  • D6-23 Headless recordOnly filter is untested — packages/cli/src/nonInteractiveCli.ts:2802 — already reported (comment 3958277731 (R3-13))
  • P9 untested producer half of the nested-wait bridge — packages/core/src/tools/agent/agent.ts:1573 — already reported (qwen-code-ci-bot stage-2 comment 2026-09-07 and the round-5 deferral list)

Unresolved, please confirm:

  • [Critical] R1-25 packages/core/src/agents/background-tasks.ts:1496 — could not be ruled on: its full body sits past the 8,000-char render cap in the recovered context and the remaining budget went to the fan-out and the round-3/4 verification tail
  • [Critical] R3-1 packages/core/src/agents/runtime/agent-progress-watchdog.ts:141 — same reason: body not read in full, so the mechanism could not be traced at HEAD
  • [Critical] R3-4 packages/core/src/agents/runtime/agent-progress-watchdog.ts:172 — same reason; note R6-1 names this entry as the counter-authority for the approval-park deadline and traces the git history of the suppression list at :104-106
  • [Critical] R1-4 packages/core/src/agents/runtime/agent-progress-watchdog.ts:104 — same reason; round 5 already marked this entry fix-induced, and R6-1 reports a new defect at the same suppression list
  • [Critical] 4 entries — same reason: body not read in full:
    • R4-1 packages/core/src/tools/agent/agent.ts:3791
    • R4-6 packages/acp-bridge/src/bridge.ts:3761
    • R4-2 packages/acp-bridge/src/bridge.ts:8652
    • R4-5 packages/core/src/tools/agent/agent.ts:3559
  • [Critical] R4-4 packages/acp-bridge/src/bridge.ts:5649 — same reason; note R6-4 is a new Critical in the same doSpawn guard pair (pre-check at :5478 tightened, post-check at :5654 left two-state)
  • [Critical] R5-1 packages/cli/src/acp-integration/session/Session.ts:9954 — same reason; D6-20 and D6-21 are new findings on the same record-only branch but claim different mechanisms
  • [Critical] R5-2 packages/core/src/tools/agent/agent.ts:3737 — same reason; N11 (rejected this round) and D6-17 (confirmed) both examined this clause but on different claims
  • [Critical] R5-3 packages/core/src/agents/background-agent-resume.ts:1367 — same reason: second location of R5-2, body not read in full
  • [Critical] R1-33 packages/cli/src/nonInteractiveCli.ts — round 5 recorded this as unresolved and it stays unresolved: the file is byte-identical between round 5's reviewed commit 85c1c2a and HEAD (git diff --numstat -> 0), so nothing changed that wou…

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were also skipped and the same unit suites ran on Linux only, so just the Linux unit ground is covered.

Not reviewed: test-efficacy — the probe harness could not be validated (harnessValidated: null; the probe tree is a fresh detached worktree that borrows node_modules but has no built dist/, so this repo's scripts/vitest-global-setup.js guard refused every run) and no revert, mutant or hunk verdict exists for this diff.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": bridgeClient.ts — I read the new handleExtMethod recycle arm but did not verify ownsSession / resolveEntry / RequestError.invalidParams internals, nor whe…; "agent reverse-audit (round 2)": nonInteractiveCli.ts — I read the localQueue push and the modelBatch filter, but did not trace emitNotificationToSdk to determine whether the SDK frame …; "agent reverse-audit (round 2)": Session.ts — I did not verify whether a recordOnly item with continuesTodoStopGuardWorkChain === false can be starved indefinitely by #nextNotificationQueu…; "agent reverse-audit (round 3)": I did not verify empirically that any real provider returns a Retry-After beyond 6 h — finding 1's trigger is established from retryPolicy.ts:106 's Date.pa…; "agent reverse-audit (round 3)": I did not determine whether this.eventEmitter (used by the background path's setupEventListeners at agent.ts:2765 ) is the same object as bgEmitter = bgSu….

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

Deferred under the convergence posture (round 6, not a blocker) — recorded, not requested in this round; 7 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:

  • packages/acp-bridge/src/bridge.ts:3780 (+1 locations) — [probe] Critical [fails-closed] [new-surface] Recycle condemns the owner before checking admissibility
  • packages/acp-bridge/src/bridge.ts:3820 (+1 locations) — [probe] Critical [fails-closed] [new-surface] Emptied draining generation is never reaped
  • packages/acp-bridge/src/bridge.ts:14435 (+1 locations) — [probe] Critical [fails-closed] [new-surface] Transient draining state reported as a non-retryable 500
  • packages/cli/src/ui/hooks/use-llm-stream.ts:6298 (+1 locations) — [probe] Critical [fails-closed] [new-surface] Interactive TUI discards the escalated terminal notification
  • packages/core/src/agents/background-tasks.ts:943 (+1 locations) — [probe] Critical [fails-closed] [new-surface] Retained slot is releasable only by the turn that never…
  • packages/core/src/agents/runtime/agent-core.ts:1306 (+1 locations) — [probe] Critical [fails-closed] [new-surface] External-input latch leaves the next model call with no…
  • packages/core/src/tools/agent/agent.ts:1570 (+1 locations) — [review] Critical [fails-closed] [new-surface] Sticky nested park flags disarm the parent watchdog
  • docs/design/background-agent-progress-watchdog.md:22 (+1 locations) — [review] Doc discloses the uncovered retry case but does not size it
  • docs/design/background-agent-runtime-generations.md:13 (+1 locations) — [probe] 'until one exits' has no mechanism behind it
  • packages/acp-bridge/src/bridge.ts:1092 (+1 locations) — [review] state field inserted between the isDying contract and field
  • packages/acp-bridge/src/bridge.ts:4603 (+3 locations) — [probe] New generation machinery and its 503 ship with zero tests
  • packages/cli/src/acp-integration/session/Session.ts:9778 (+1 locations) — [review] A rejected persist loses the kill from the transcript…
  • packages/cli/src/acp-integration/session/Session.ts:9958 (+1 locations) — [probe] Daemon-side recordOnly invariant is unpinned
  • packages/cli/src/acp-integration/session/Session.ts:9958 (+1 locations) — [review] recordOnly display rides the automatic-turn admission gates
  • packages/cli/src/ui/utils/backgroundWorkUtils.ts:100 (+1 locations) — [probe] Retained-slot branch of the blocking-work list untested
  • packages/cli/src/ui/utils/backgroundWorkUtils.ts:108 (+1 locations) — [probe] 'still stopping' marker is clipped by the width clamp
  • packages/core/src/agents/background-agent-resume.ts:1287 (+1 locations) — [probe] Model deadline stays armed across silent hook phases
  • packages/core/src/agents/background-tasks.ts:929 (+1 locations) — [probe] Escalation sidecar patch drops the terminal summary
  • packages/core/src/agents/background-tasks.ts:943 (+1 locations) — [probe] Retained-slot release and accounting sites are untested
  • packages/core/src/agents/background-tasks.ts:1873 (+1 locations) — [review] pruneTerminalEntries contract docstring is now false
  • …and 7 more (see the run report)

Convergence: round 6 posted 6 inline comment(s), 5 of them reported for the first time; the previous round posted 13 (3 new). Findings keep coming back to the same files: packages/core/src/agents/runtime/agent-progress-watchdog.ts (findings in rounds 1, 3; 2 more now); packages/core/src/tools/agent/agent.ts (findings in rounds 4, 5; 1 more now); packages/acp-bridge/src/bridge.ts (findings in round 4; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

[Critical] R6-5 [certifies-falsely] [new-surface] packages/core/src/agents/background-tasks.ts:1489 — The whole retainsPhysicalSlot protection is unreachable whenever anything other than the watchdog aborts the turn first — the escalation is armed only inside abort(), whose first statement early-returns on an already-aborted signal, and a user cancel aborts the same controller. retainsPhysicalSlot has exactly one write site (failUnresponsive, background-tasks.ts:928), whose only callers are the two watchdog escalation callbacks; the escalation is armed only inside abort(), whose first statement is if (disposed || controller.signal.aborted) return; (agent-progress-watchdog.ts:68). A background agent's tool ignores AbortSignal — the exact premise the field documents. The user stops it via task_stop//tasks -> cancel() (task-stop.ts:76) -> entry.abortController.abort() + status cancelled + a CANCEL_GRACE_MS timer, and CANCEL_GRACE_MS = 5000 against a 10 min tool / 15 min model deadline, so cancel-first is the NORMAL ordering — a human stops a stuck agent long before 15 minutes of silence. Five seconds later finalizeCancellationIfPending -> emitNotification sets notified, so getRunningBackgroundCount's (status === 'cancelled' && !entry.notified) goes false with retainsPhysicalSlot never set, and its trailing drainWaitQueue() EAGERLY ADMITS A QUEUED LAUNCH ON TOP OF THE STILL-EXECUTING RUN, past maxConcurrentBackgroundAgents; hasRunningTasks() goes false, so /clear, /new, /resume and /branch pass hasBlockingBackgroundWork() and reset() erases the only remaining owner of a live run. Every later watchdog deadline hits abort()'s early-return, so failUnresponsive never runs: no recordOnly notification, no sidecar patch, and no runtime recycle request for the wedged generation — the PR's headline remedy. The added test encodes only the reverse ordering, which is why it is green. This blocker could not be posted inline: its anchor line collides with an existing comment at the same location (3958277693, qwen-code-ci-bot, [Suggestion] R3-9) that reports a different claim, so the overlap rule dropped the inline copy; it is carried here so the blocker is not lost. Fix constraint: hasRunningTasks() must keep excluding a cancelled-and-notified entry that has NOT retained a slot — packages/cli/src/ui/utils/backgroundWorkUtils.ts:18-23: "Gating on the unfinalized set made /new silently no-op when typed in the window between cancel and finalize (issue #5949)." So the fix must set the flag on the escalation path, not widen the hasRunningTasks()/getRunningBackgroundCount predicates to status === 'cancelled'. Fix witness: packages/core/src/agents/background-tasks.test.ts — register a backgrounded agent, call registry.cancel(id), advance past CANCEL_GRACE_MS (so notified is true and the slot is released), then invoke the escalation the way the watchdog would and assert entry.retainsPhysicalSlot === true, registry.hasRunningTasks() === true and getRunningBackgroundCount unchanged; paired with an agent-progress-watchdog.test.ts case that aborts the controller externally, advances past the model deadline + 5 s, and asserts onUnresponsive was called. Both go red with if (disposed || controller.signal.aborted) return; restored as the whole-body early return.

中文说明

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

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

未决,请确认:共 13 条(原文未翻译,列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were also skipped and the same unit suites ran on Linux only, so just the Linux unit ground is covered.

未审查(原文为英文):test-efficacy — the probe harness could not be validated (harnessValidated: null; the probe tree is a fresh detached worktree that borrows node_modules but has no built dist/, so this repo's scripts/vitest-global-setup.js guard refused every run) and no revert, mutant or hunk verdict exists for this diff.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"bridgeClient.ts — I read the new handleExtMethod recycle arm but did not verify ownsSession / resolveEntry / RequestError.invalidParams internals, nor whe…"agent reverse-audit (round 2)"nonInteractiveCli.ts — I read the localQueue push and the modelBatch filter, but did not trace emitNotificationToSdk to determine whether the SDK frame …"agent reverse-audit (round 2)"Session.ts — I did not verify whether a recordOnly item with continuesTodoStopGuardWorkChain === false can be starved indefinitely by #nextNotificationQueu…"agent reverse-audit (round 3)"I did not verify empirically that any real provider returns a Retry-After beyond 6 h — finding 1's trigger is established from retryPolicy.ts:106 's Date.pa…"agent reverse-audit (round 3)"I did not determine whether this.eventEmitter (used by the background path's setupEventListeners at agent.ts:2765 ) is the same object as bgEmitter = bgSu…

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

收敛姿态下延后(第 6 轮,非阻断)——已记录,本轮不要求修改;其中 7 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 27 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 6 轮发布了 6 条行内评论,其中 5 条是首次提出;上一轮发布了 13 条(其中 3 条首次提出)。发现反复回到同一批文件:packages/core/src/agents/runtime/agent-progress-watchdog.ts(第 1、3 轮已出过发现,本轮又有 2 条);packages/core/src/tools/agent/agent.ts(第 4、5 轮已出过发现,本轮又有 1 条);packages/acp-bridge/src/bridge.ts(第 4 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

[Critical] R6-5 [certifies-falsely] [new-surface] packages/core/src/agents/background-tasks.ts:1489 — The whole retainsPhysicalSlot protection is unreachable whenever anything other than the watchdog aborts the turn first — the escalation is armed only inside abort(), whose first statement early-returns on an already-aborted signal, and a user cancel aborts the same controller. retainsPhysicalSlot has exactly one write site (failUnresponsive, background-tasks.ts:928), whose only callers are the two watchdog escalation callbacks; the escalation is armed only inside abort(), whose first statement is if (disposed || controller.signal.aborted) return; (agent-progress-watchdog.ts:68). A background agent's tool ignores AbortSignal — the exact premise the field documents. The user stops it via task_stop//tasks -> cancel() (task-stop.ts:76) -> entry.abortController.abort() + status cancelled + a CANCEL_GRACE_MS timer, and CANCEL_GRACE_MS = 5000 against a 10 min tool / 15 min model deadline, so cancel-first is the NORMAL ordering — a human stops a stuck agent long before 15 minutes of silence. Five seconds later finalizeCancellationIfPending -> emitNotification sets notified, so getRunningBackgroundCount's (status === 'cancelled' && !entry.notified) goes false with retainsPhysicalSlot never set, and its trailing drainWaitQueue() EAGERLY ADMITS A QUEUED LAUNCH ON TOP OF THE STILL-EXECUTING RUN, past maxConcurrentBackgroundAgents; hasRunningTasks() goes false, so /clear, /new, /resume and /branch pass hasBlockingBackgroundWork() and reset() erases the only remaining owner of a live run. Every later watchdog deadline hits abort()'s early-return, so failUnresponsive never runs: no recordOnly notification, no sidecar patch, and no runtime recycle request for the wedged generation — the PR's headline remedy. The added test encodes only the reverse ordering, which is why it is green. This blocker could not be posted inline: its anchor line collides with an existing comment at the same location (3958277693, qwen-code-ci-bot, [Suggestion] R3-9) that reports a different claim, so the overlap rule dropped the inline copy; it is carried here so the blocker is not lost. Fix constraint: hasRunningTasks() must keep excluding a cancelled-and-notified entry that has NOT retained a slot — packages/cli/src/ui/utils/backgroundWorkUtils.ts:18-23: "Gating on the unfinalized set made /new silently no-op when typed in the window between cancel and finalize (issue #5949)." So the fix must set the flag on the escalation path, not widen the hasRunningTasks()/getRunningBackgroundCount predicates to status === 'cancelled'. Fix witness: packages/core/src/agents/background-tasks.test.ts — register a backgrounded agent, call registry.cancel(id), advance past CANCEL_GRACE_MS (so notified is true and the slot is released), then invoke the escalation the way the watchdog would and assert entry.retainsPhysicalSlot === true, registry.hasRunningTasks() === true and getRunningBackgroundCount unchanged; paired with an agent-progress-watchdog.test.ts case that aborts the controller externally, advances past the model deadline + 5 s, and asserts onUnresponsive was called. Both go red with if (disposed || controller.signal.aborted) return; restored as the whole-body early return.

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

Comment thread packages/core/src/agents/runtime/agent-progress-watchdog.ts

const MODEL_CONTROL_PROGRESS_TIMEOUT_MS = 15 * 60_000;
const TOOL_PROGRESS_TIMEOUT_MS = 10 * 60_000;
const UNRESPONSIVE_ABORT_GRACE_MS = 5_000;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-2: [certifies-falsely] [new-surface] The 5-second escalation grace is measured against the entire cooperative unwind, so an agent that obeys the abort but takes longer than 5 s to settle is force-failed, its terminal payload is discarded, and a full runtime-generation recycle is spent on an agent that was cooperating.

abort() calls controller.abort(error) and arms the escalation. The abort then has to propagate out through the model stream, the CoreToolScheduler cancellation of the very tool that has been unresponsive for ten minutes, await cleanupWorktreeIsolation() (a git worktree removal), patchAgentMeta and span recording before bgBody resolves and its .finally() reaches disposeWatchdog(). The population that triggers a tool-phase watchdog is strongly correlated with the population that cannot unwind in 5 s — a hung shell child, an MCP call awaiting a dead peer, a fetch with no timeout.

When the unwind lands at, say, T+8 s: failUnresponsive has already set status='failed', notified=true, retainsPhysicalSlot=true, patched the sidecar lastError with the bare timeout string and emitted a recordOnly notification; Session.#recordUnresponsiveAgentNotification has already fired qwen/control/session/runtime/recycle, so the daemon marks the generation draining and spawns a replacement ACP child. agent.ts:3765-3768 then computes errorMsg = baseErrorMsg + wtSuffix and returns, discarding it; agent.ts:3654 computes finalText (with wtSuffix) and breaks, discarding it. registry.complete/finalizeCancelled/fail all short-circuit on entry.notified, so nothing re-publishes. The user's failure text and the persisted lastError say only "made no progress for 600000ms" and never name the preserved worktree path/branch — precisely the outcome the surviving comment at agent.ts:3752-3757 warns against ("an agent that crashed mid-edit would have its worktree preserved on disk but the user would never see its location in the failure notification — they would assume nothing was left behind").

Witness: the conditional itself ("obeys the abort but takes >5 s") is measured by probe against the real watchdog and registry; the trigger realism is corroborated externally by the maintainer's own harness at anchor 25df3993, which measured a settle 33 s after the watchdog abort for a run that was not ignoring the signal but sleeping in a delay that does not observe it (+0.21s <429 Retry-After: 45> … +45.40s settled: failed, 12 s deadline; "the sleep is not interrupted … The retry loop only observes the abort after the full sleep elapses"). baseline: new-surface settled against the merge base: attachAgentProgressWatchdog / failUnresponsive appear in 0 files at 558d7f2290. Not measured: how often a real worktree-cleanup unwind exceeds 5 s on production disks — that frequency is the residual uncertainty.

Suggested fix: do not let the escalation be irreversible on a cooperative settlement. Either widen the grace well past the cooperative-unwind budget (worktree cleanup + scheduler cancellation), or have the terminal paths re-publish the real payload when the entry is retainsPhysicalSlot but the run did settle — in agent.ts / background-agent-resume.ts, when retainsPhysicalSlot is set, still write finalText/errorMsg (including wtSuffix) into entry.error and patchAgentMeta({ lastError }) before break/return, and skip the recycle request when the turn settled rather than hung.

Any fix must respect this: const CANCEL_GRACE_MS = 5000; (packages/core/src/agents/background-tasks.ts:157), used at :1011 — the registry already treats "5 s after an abort with no natural settlement" as the normal case needing only a deferred finalizeCancellationIfPending() fallback, and its comment states the fallback exists for "the rare case where a tool ignores AbortSignal and bgBody never settles". A new bound must not reuse that same 5 s budget for a strictly harsher consequence (terminal failed + retained slot + runtime recycle) on the same population.

Please add the test that pins this: in packages/core/src/tools/agent/agent.test.ts, a background-agent case that stalls a tool past the tool deadline, lets the turn body settle cooperatively after the grace window, and asserts (1) the registry entry's error still contains the preserved-worktree suffix and (2) no qwen/control/session/runtime/recycle was requested. Removing the fix must turn both red. The existing background-tasks.test.ts case calls failUnresponsive directly and so cannot see this path.

中文说明

[Critical] R6-2:5 秒升级宽限期是按整个协作式收尾过程来计量的,因此一个服从了中止、只是收尾耗时超过 5 秒的 Agent 会被强制判为失败,它的终态内容被丢弃,而且一次完整的 runtime generation 回收被花在了一个本来在配合的 Agent 上。

abort() 会调用 controller.abort(error) 并武装升级定时器。随后这个中止必须依次穿过:模型流、CoreToolScheduler那个已经十分钟无响应的工具本身的取消、await cleanupWorktreeIsolation()(一次 git worktree 删除)、patchAgentMeta 以及 span 记录,bgBody 才会 resolve,其 .finally() 才能执行到 disposeWatchdog()。会触发工具阶段 watchdog 的那类运行,与无法在 5 秒内完成收尾的那类运行高度重合 —— 卡死的 shell 子进程、等待已死对端的 MCP 调用、没有超时的 fetch。

假设收尾在 T+8 秒才落地:此时 failUnresponsive 已经设置 status='failed'notified=trueretainsPhysicalSlot=true,已用光秃秃的超时字符串写入 sidecar 的 lastError,并发出了 recordOnly 通知;Session.#recordUnresponsiveAgentNotification 也已发出 qwen/control/session/runtime/recycle,于是 daemon 把该 generation 标记为 draining 并派生出替代的 ACP 子进程。接着 agent.ts:3765-3768 算出 errorMsg = baseErrorMsg + wtSuffix直接 return 把它丢弃agent.ts:3654 算出带 wtSuffixfinalTextbreak 把它丢弃registry.complete/finalizeCancelled/fail 都因 entry.notified 短路,所以没有任何路径会重新发布。用户看到的失败文本与持久化的 lastError 只有“made no progress for 600000ms”,永远不会提到被保留的 worktree 路径与分支 —— 这正是 agent.ts:3752-3757 那段留存注释所警告的结果(“一个在编辑中途崩溃的 agent,其 worktree 会被保留在磁盘上,但用户永远看不到它在失败通知中的位置 —— 他们会以为什么都没留下”)。

证据:“服从中止但收尾超过 5 秒”这一条件本身已由探针在真实 watchdog 与 registry 上实测;触发的现实性另有外部佐证 —— 维护者在 anchor 25df3993 的实测环境中,测得一个并未忽略信号、只是睡在一个不观察该信号的 delay 里的运行,在 watchdog 中止后 33 秒才结算(+0.21s <429 Retry-After: 45> … +45.40s settled: failed,期限 12 秒;“该 sleep 不会被打断……重试循环只有在整段 sleep 结束后才会观察到中止”)。baseline: new-surface 已对合并基线核实:attachAgentProgressWatchdog / failUnresponsive558d7f2290 中出现于 0 个文件。未实测部分:真实磁盘上 worktree 清理收尾超过 5 秒的频率 —— 这是残余不确定性所在。

修复建议:不要让升级在“协作式结算”的情形下不可逆。要么把宽限期放宽到明显超过协作收尾所需预算(worktree 清理 + 调度器取消),要么在终态路径上,当条目处于 retainsPhysicalSlot 但运行确实结算了时重新发布真实内容 —— 即在 agent.ts / background-agent-resume.ts 中,当 retainsPhysicalSlot 已设置时,在 break/return 之前仍把 finalText/errorMsg(含 wtSuffix)写入 entry.errorpatchAgentMeta({ lastError });并在“turn 是结算而非挂死”的情况下跳过回收请求。

修复必须遵守:const CANCEL_GRACE_MS = 5000;packages/core/src/agents/background-tasks.ts:157,在 :1011 使用)—— registry 已经把“中止后 5 秒仍未自然结算”视为正常情况,只需要一个延后的 finalizeCancellationIfPending() 兜底,其注释也写明该兜底是为“工具忽略 AbortSignal 且 bgBody 永不结算的罕见情况”准备的。新的上界不得把同一个 5 秒预算,用于对同一类运行施加严格更重的后果(终态 failed + 保留槽位 + runtime 回收)。

请补上能钉住这一点的测试:在 packages/core/src/tools/agent/agent.test.ts 中增加一个后台 agent 用例 —— 让某个工具停滞超过工具期限,让 turn 主体在宽限期之后才协作式结算,并断言(1)registry 条目的 error 仍包含被保留 worktree 的后缀,(2)没有发出 qwen/control/session/runtime/recycle。移除修复时两者都必须变红。现有的 background-tasks.test.ts 用例是直接调用 failUnresponsive 的,因此看不到这条路径。

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

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.

Not fixing — escalating. This is one of three findings (R6-1, R6-2, R6-3) that all change watchdog/abort semantics, which is the human-gated class on a PR whose entire subject is timeout semantics. Verified at head:

  • UNRESPONSIVE_ABORT_GRACE_MS = 5_000 (agent-progress-watchdog.ts:19) is a single constant, and it is measured against the whole cooperative unwind — which includes CoreToolScheduler cancelling the very tool that has already been unresponsive for ten minutes, plus cleanupWorktreeIsolation() (a git worktree removal), patchAgentMeta and span recording before bgBody's .finally() reaches disposeWatchdog().

The population that triggers a tool-phase watchdog is correlated with the population that cannot unwind in 5 s, so the premise is sound rather than hypothetical.

The gate condition: making the escalation reversible, or lengthening the grace, is a decision about who owns the terminal record. Today failUnresponsive sets notified = true and every settle path (complete / finalizeCancelled / fail) short-circuits on it, so a late cooperative unwind computes errorMsg and finalText and then discards them. Fixing that means choosing whether the watchdog or the agent body owns the terminal payload — an ownership change in the settle path — and it interacts with the runtime recycle that Session.#recordUnresponsiveAgentNotification has already fired by then, so the answer also decides whether a spent generation is recoverable.

That is concurrency/settle semantics on the exact mechanism this PR introduces, and the report itself flags the residual uncertainty honestly (how often a real worktree cleanup exceeds 5 s on production disks was not measured). Not a closeout-pass decision. Leaving unresolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-2: [certifies-falsely] [new-surface] The 5-second escalation grace is measured against the entire cooperative unwind, so an agent that obeys the abort but takes longer than 5 s to settle is force-failed, its terminal payload is discarded, and a full runtime-generation recycle is spent on an agent that was cooperating.

Failure scenario: abort() calls controller.abort(error) and immediately arms a 5 s escalation. The unwind it is racing includes CoreToolScheduler cancelling a tool that has already been unresponsive for ten minutes, plus cleanupWorktreeIsolation and the registry settle path. An agent that obeys the abort but needs longer than 5 s to settle is recorded as failed/unresponsive, its real terminal payload is discarded, and Session.ts requests a runtime-generation recycle for a runtime that was cooperating.

Witness:

Read at HEAD 8b23578fc9: `const UNRESPONSIVE_ABORT_GRACE_MS = 5_000;` (packages/core/src/agents/runtime/agent-progress-watchdog.ts:19) is still a single constant, and abort() (:67-78) calls controller.abort(error) then armEscalation(), which schedules UNRESPONSIVE_ABORT_GRACE_MS with armEscalation itself as the re-arm callback — so the grace spans the whole cooperative unwind and re-fires. The author's own reply on this thread verified the same anchor at head and answered 'Not fixing — escalating'; a reply does not retire a blocker, and no commit changed it.

Suggested fix: Give the tool deadline the same extension the model deadline has. Carry the nested retry delay out on the task_execution display (set it in the MODEL_RETRY handler next to forwardProgress, clear it on ROUND_START/STREAM_TEXT), surface it as an optional retryDelayMs on AgentToolProgressEvent in agent-core.ts's outputUpdateHandler, and in armTool schedule TOOL_PROGRESS_TIMEOUT_MS + Math.min(event.retryDelayMs ?? 0, MAX_RETRY_DEADLINE_EXTENSION_MS), re-arming through a closure that preserves the granted delay exactly as armModel does.

The fix must not violate this existing fact: The tool-side extension must stay within the same bound the model side uses — const MAX_RETRY_DEADLINE_EXTENSION_MS = 6 * 60 * 60_000; (packages/core/src/agents/runtime/agent-progress-watchdog.ts:19) — which is deliberately equal to const PERSISTENT_CAP_MS = 6 * 60 * 60 * 1000; // 6 hours — absolute single wait cap (packages/core/src/utils/retry.ts:25). A smaller cap re-int

Acceptance criterion: packages/core/src/agents/runtime/agent-progress-watchdog.test.ts — a case mirroring the existing 'keeps a provider-backoff deadline extension across a clock-drift re-arm': toolCall('t1'); toolProgress('t1'); toolProgress('t1', { retryDelayMs: 30 * 60_000 }), then assert abortPhase() is still undefined at TOOL_TIMEOUT_MS + 1 and becomes 'tool' at `TOOL_TIMEOUT_MS + Please confirm the mutation that proves it (remove the guard, run that test, confirm it goes red).

中文说明

UNRESPONSIVE_ABORT_GRACE_MS = 5_000 仍是单一常量,且 abort()controller.abort(error) 之后立即装配升级定时器,并以 armEscalation 自身作为重新装配回调。因此这 5 秒宽限覆盖的是整个协作式收尾过程(包括调度器取消那个已无响应十分钟的工具、worktree 清理、registry 落定)。一个服从中止但收尾超过 5 秒的 Agent 会被判定为 unresponsive、真实终止负载被丢弃,并为一个本来在协作的运行时花费一次完整的 generation 回收。

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

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.

Real bug, but the fix is a design decision I did not want to settle unilaterally in this pass. The escalation grace only resets on dispose() (agent settle). Re-arming on settle signals (TOOL_RESULT / TOOL_PROGRESS(settled)) does not cover the motivating case — a single tool whose cancellation takes >5s emits its settle signal after the grace has already fired. Re-arming on all activity (e.g. STREAM_TEXT) defeats the escalation for an abort-ignoring agent that keeps streaming. Which signals reset the grace, and whether to add a bounded total grace, needs a decision. Left unresolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-2: [certifies-falsely] [new-surface] Still stands — the 5-second escalation grace is measured against the entire cooperative unwind, so an agent that obeys the abort but takes longer than 5 s to settle is force-failed, its terminal payload is discarded, and a full runtime-generation recycle is spent on an agent that was cooperating

abort() calls controller.abort(error) and immediately arms the 5 s escalation with armEscalation itself as the re-arm callback, so the grace spans the whole unwind — CoreToolScheduler cancelling a tool already unresponsive for ten minutes, cleanupWorktreeIsolation, and the registry settle path. An agent that obeys but needs longer than 5 s is recorded failed/unresponsive, its real terminal payload is discarded, and Session.ts requests a runtime-generation recycle for a runtime that was cooperating.

Witness:

not run — scratch-tree refused (available: false, repo-local git include chain); settled by quoted lines at HEAD 90e2d6f7ae: `const UNRESPONSIVE_ABORT_GRACE_MS = 5_000;` (agent-progress-watchdog.ts:19) and abort() at :67-78 calling controller.abort(error) then armEscalation(), which schedules UNRESPONSIVE_ABORT_GRACE_MS with armEscalation as its own re-arm callback. The author replied "Not fixing — escalating"; a reply does not retire a blocker and no commit changed the constant.

Suggested fix:
Give the escalation a settle signal to stand down on (re-arm on TOOL_RESULT / TOOL_PROGRESS(settled) / dispose) or make the grace measurable against the unwind rather than a fixed 5 s, and record which signals count.

The fix must not violate this existing fact: UNRESPONSIVE_ABORT_GRACE_MS = 5_000 (agent-progress-watchdog.ts:19) and CANCEL_GRACE_MS = 5000 (background-tasks.ts:157) are the two 5 s windows this PR adds; a stand-down must not let an abort-ignoring run escape escalation entirely, which is the case the constant exists for.

Please confirm the mutation that proves it — packages/core/src/agents/runtime/agent-progress-watchdog.test.ts — a case that aborts, emits a settle signal after more than UNRESPONSIVE_ABORT_GRACE_MS, and asserts onUnresponsive was never called. It goes red if the stand-down is removed.

中文说明

UNRESPONSIVE_ABORT_GRACE_MS = 5_000 仍是单一常量,abort()controller.abort(error) 之后立刻装配升级定时器,并以 armEscalation 自身作为重新装配回调。因此这 5 秒宽限覆盖的是整个协作式收尾过程——包括调度器取消那个已无响应十分钟的工具、cleanupWorktreeIsolation,以及注册表落定路径。

一个服从中止、但收尾超过 5 秒的 Agent 会被判定为 unresponsive:真实终止负载被丢弃,并为一个本来在协作的运行时花费一次完整的 generation 回收。

作者已回复“不修复——上报维护者裁决”。回复本身不能撤销阻塞项,且没有提交改动这段代码。

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

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.

Design-level — left open deliberately, not silently resolved. The escalation grace is measured against the entire cooperative unwind, and narrowing that means re-specifying watchdog semantics: whether observed progress restarts the grace, what counts as "settling", and how the terminal payload survives a force-fail. That is a semantics change to agent-progress-watchdog.ts, beyond a mechanical guard fix, so it is not being landed inside this fix round. Evidence anchor: packages/core/src/agents/runtime/agent-progress-watchdog.ts:19 (UNRESPONSIVE_ABORT_GRACE_MS) plus the drift-guarded re-arm at :85-92.

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.

Re-verified at the post-merge head 93eef769a7 (this thread was last answered against 90e2d6f7ae). The finding is real, and the fix is still a policy decision rather than a guard.

Anchors at 93eef769a7: UNRESPONSIVE_ABORT_GRACE_MS = 5_000 (packages/core/src/agents/runtime/agent-progress-watchdog.ts:19); abort() at :67-78 arms the escalation with armEscalation itself as the re-arm callback; the only clear is the watchdog detach, which runs in the turn promise's .finally (packages/core/src/tools/agent/agent.ts:3881, :3916). cleanupWorktreeIsolation(), the registry settle and patchAgentMeta all run inside bgBody, i.e. inside that same promise — so the whole unwind is inside the 5 s window.

Damage confirmed: failUnresponsive sets status='failed' + notified + retainsPhysicalSlot (packages/core/src/agents/background-tasks.ts:935, :943); complete() and fail() bail on status/notified (:900ff) and cancel() requires running, so the settling turn's payload and its worktree suffix are dropped; Session.ts:9933 -> #recordUnresponsiveAgentNotification (:10142) requests the recycle with reason unresponsive_agent (:10171).

Why this is not a guard fix: the round-8 ask ("give the escalation a settle signal to stand down on") is a semantics spec, and the settle-signal variant does not cover the motivating case — the TOOL_RESULT/TOOL_PROGRESS(settled) of the very tool whose cancellation is the slow part arrive after the grace fires, while re-arming on STREAM_TEXT lets an abort-ignoring streamer live forever.

Question for the maintainer: pick the policy — (a) a bounded total grace that covers the cooperative unwind, keeping the hard force-fail, or (b) keep 5 s but let a late cooperative settle win (when retainsPhysicalSlot is set and the turn does settle, still publish the real payload incl. wtSuffix into entry.error/patchAgentMeta, and skip the recycle). Option (b) crosses agent.ts + background-tasks.ts + Session.ts and inverts terminal-outcome precedence. Leaving unresolved pending that decision.

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.

Round jmtz4tb6u1d independent re-verification at head 93eef769a7. Re-derived from source rather than inherited from the previous round. REAL, and human-gated. One anchor the earlier replies did not spell out:

The escalation has exactly one clear point, and it is coupled to the very thing it is racing.

  • agent-progress-watchdog.ts:19 UNRESPONSIVE_ABORT_GRACE_MS = 5_000; abort() at :67-78 arms it with armEscalation as its own drift re-arm callback.
  • The only clear is :239 (if (escalationTimer) clearTimeout(escalationTimer)) inside the detach closure, and that closure runs at agent.ts:3915 in the turn promise's .finally -- on the same line-pair as registry.releaseRetainedPhysicalSlot(hookOpts.agentId) (agent.ts:3916).
  • Repo-wide grep, non-test: releaseRetainedPhysicalSlot has exactly two call sites -- agent.ts:3916 and background-agent-resume.ts:1437. Both are inside that same .finally.

So the unwind the 5 s grace is measured against (cleanupWorktreeIsolation, scheduler cancellation of the already-hung tool, registry settle, patchAgentMeta, span recording) runs inside the only promise whose .finally can stop the escalation. There is no observation point outside it. That is also why the round-8 "give the escalation a settle signal to stand down on" cannot cover the motivating case: the settle signal of the slow tool arrives after the grace has fired, while re-arming on STREAM_TEXT lets an abort-ignoring streamer live forever.

Gate condition hit: architecture/design decision -- changing concurrency-primitive semantics. The decision underneath is who owns the terminal record once failUnresponsive has run (background-tasks.ts:935-956: status='failed' at :940, retainsPhysicalSlot at :943, meta patched, emitNotification(entry, true)), and whether a late cooperative settle may win over it. Maintainer options, unchanged: (a) a bounded total grace that actually covers the cooperative unwind, keeping the hard force-fail; or (b) keep 5 s and let a late cooperative settle re-publish the real payload (including the worktree suffix) and skip the recycle. (b) crosses agent.ts + background-tasks.ts + Session.ts and inverts terminal-outcome precedence.

No code change this round. Leaving unresolved rather than silently resolving a Critical I declined to fix.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-2: [certifies-falsely] [new-surface] Still stands — the 5-second escalation grace is measured against the entire cooperative unwind, so an agent that obeys the abort but takes longer than 5 s to settle is force-failed with a message asserting it made no progress, its terminal payload is discarded, and a runtime-generation recycle is spent on an agent that was cooperating.

abort() calls controller.abort(error) and immediately arms the escalation. Nothing but disposeWatchdog() clears that timer, and the disposer runs only from the run body's .finally. What the 5 s has to cover is the whole unwind: CoreToolScheduler awaits Promise.all(executing) with each invocation.execute(...) awaited directly and no abort race, and the run body then awaits worktree teardown before it reaches the payload guard — cleanupWorktreeIsolation spawns up to three git subprocesses. Once the escalation wins, failUnresponsive publishes failed and agent.ts's if (registry.get(agentId)?.retainsPhysicalSlot) break; discards the terminal payload the cooperating run had already produced.

The drift guard does not tighten this bound: if (performance.now() - expectedAt > 1_000) { rearm(); return; } can only push the escalation later, by another full 5 s per stall. The nested case is the unargued sibling — the parent's 5 s must cover the child's entire unwind. The design doc fixes the window deliberately ("a fixed five-second cooperative exit window") but offers no margin argument for what the window measures.

Confidence is qualified, and stated plainly so it is not overread: the mechanism is settled by run, but whether a real cooperative unwind exceeds 5 s was not measured — that needs a live model-backed background run, mid-task with a nested agent or worktree-isolated, at the moment a tool deadline expires.

Witness:

probe on the PR's own watchdog under a scaled clock:
ARM A (dispose inside the grace)              {"abortedAfterMs":6003,"phase":"tool","onUnresponsiveCalls":0}
ARM B (still unwinding 200 ms after the abort, disposed later)
                                              {"onUnresponsiveBeforeDispose":1,
                                               "messages":["Background agent tool \"run_shell_command\" made no progress for 600000ms."]}
structural half: escalationTimer is written only at agent-progress-watchdog.ts:71 and cleared only inside the returned dispose closure

Measure the grace from the point the run stops making progress rather than from the abort, or have the escalation consult whether the turn is still unwinding (a flag the run body sets on cooperative exit) before force-failing. At minimum, do not discard a terminal payload the cooperating run already produced.

Please add the test that pins this: a watchdog test that aborts, keeps the turn unwinding past the grace, and asserts onUnresponsive does NOT fire while a cooperative-unwind flag is set — red if the flag is ignored.

中文说明

[Critical] R6-2:依然存在——5 秒升级宽限是针对整个协作式退出过程计时的,因此一个服从 abort、但需要超过 5 秒才能结算的 Agent 会被强制判为失败,并被告知"没有任何进展",其终态载荷被丢弃,同时为这个本来在配合的 Agent 白白消耗一次 runtime generation 回收。

abort() 调用 controller.abort(error) 后立即启动升级定时器。除 disposeWatchdog() 之外没有任何东西会清除它,而该 disposer 只从 run body 的 .finally 调用。这 5 秒需要覆盖的是整个退出过程:CoreToolSchedulerawait Promise.all(executing),其中每个 invocation.execute(...) 都被直接 await 且没有 abort 竞争;随后 run body 在到达载荷判断之前还要 await worktree 拆除——cleanupWorktreeIsolation 最多会派生三个 git 子进程。一旦升级获胜,failUnresponsive 会发布 failed,而 agent.tsif (registry.get(agentId)?.retainsPhysicalSlot) break; 会丢弃这个配合退出的 run 已经产出的终态载荷。

漂移保护并不会收紧这个上界:if (performance.now() - expectedAt > 1_000) { rearm(); return; } 只能把升级往后推,每次阻塞再推整整 5 秒。嵌套场景是未被论证的同类问题——父级的 5 秒必须覆盖子级的整个退出过程。设计文档有意固定了这个窗口("a fixed five-second cooperative exit window"),但对窗口所度量的内容没有给出任何余量论证。

置信度有所限定,此处明确说明以免被过度解读:机制已由运行验证,但真实的协作式退出是否会超过 5 秒未被测量——这需要一个真实的、由模型驱动的后台 run,在其处于 task 嵌套或 worktree 隔离状态、且工具期限到期的那一刻进行观测。

修复方向:从 run 停止产生进展的时刻开始计时,而不是从 abort 开始;或让升级在强制判失败之前先判断该轮是否仍在退出过程中(由 run body 在协作退出时置位的标志)。至少不要丢弃配合退出的 run 已经产出的终态载荷。

请补充测试:一个 watchdog 用例,先 abort,再让该轮在宽限期之后仍处于退出过程,并断言在协作退出标志置位期间 onUnresponsive 不会触发;若该标志被忽略,测试必须为红。

(证据见上方 Witness 代码块;witness 为程序输出,未翻译。)

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

this.updateDisplay({}, updateOutput);
};
eventEmitter.on(AgentEventType.STREAM_TEXT, forwardProgress);
eventEmitter.on(AgentEventType.MODEL_RETRY, forwardProgress);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-3: [certifies-falsely] [new-surface] The new retry-backoff deadline extension cannot cross a nesting boundary — a nested run's MODEL_RETRY is downgraded to a flagless forwardProgress heartbeat, so the watched background agent's tool deadline stays at the fixed 10 minutes while the nested run sleeps out a provider-directed backoff.

Background agent B (watchdog on bgEmitter, agent.ts:3824) spawns nested agent C in the foreground, so setupEventListeners(updateOutput, sessionWorkflowAgent, runtimeEventEmitter) (agent.ts:3123) is C's only progress channel into B's scheduler. C's model call hits a 429 whose Retry-After is 3600 s. retryWithBackoff deliberately preserves it unclamped (actualDelayMs = retryAfterMs, packages/core/src/utils/retry.ts:496-500) and fires onRetry once before the sleep. C's own emitter gets one MODEL_RETRY — which agent-core.ts:1000 would turn into a 15 min + delay extension if C were the watched agent, but here it only reaches forwardProgress, which calls updateDisplay({}, updateOutput). B's outputUpdateHandler (agent-core.ts:1977-1992) then emits TOOL_PROGRESS for C's callId, and AgentToolProgressEvent (agent-events.ts:181-194) has no delay field, so B's watchdog takes the executing branch and re-arms exactly TOOL_PROGRESS_TIMEOUT_MS = 10 * 60_000. Nothing else emits during the sleep (persistent-mode heartbeatFn writes to stderr, not the emitter), so at 10 minutes into a legitimate rate-limit wait B is aborted with Background agent tool "agent" made no progress for 600000ms, C is killed through the child abort controller, and the registry settles B as failed — a false terminal record for a run that was honouring a provider-directed wait, on the exact retry path the onRetry plumbing was added to cover. Aggravating: forwardProgress throttles to 1/s, so a MODEL_RETRY arriving within 1 s of any other forwarded event is dropped entirely and the parent's clock keeps running from the previous heartbeat.

Witness (probe, pristine source, 6/6 pass):

P5 extends the model deadline by the reported retry delay -> PASS
   (MODEL_RETRY retryDelayMs=3 600 000 => no abort at 15 m + 1 h - 1 s, abort 'model/control' at +1 s)
P6 a flagless nested heartbeat gives a long retry backoff only 10 minutes -> PASS
   (one flagless TOOL_PROGRESS => abortPhase() === 'tool' at exactly TOOL_PROGRESS_TIMEOUT_MS)

P5/P6 together are the asymmetry: the same one-hour provider wait buys 75 minutes when the watched agent reports it and 10 minutes when a nested run reports it — and the flagless heartbeat also runs clearModel() (:198), so it removes any model deadline the parent had. Trigger realism: the repo's own suite pins a 10-minute provider wait as a case that must be honoured — retry.test.ts:1344 "should respect oversized Retry-After values for normal retries" asserts a setTimeout delay of 600_000 for retry-after: '600' — which is exactly TOOL_PROGRESS_TIMEOUT_MS. Any Retry-After above that in a nested run aborts the background agent. baseline: new-surface: the watchdog, MODEL_RETRY and the whole abort path are added by this commit.

Suggested fix: give the propagation channel a delay. Add retryDelayMs?: number to AgentToolProgressEvent and to the nested task_execution display, have C's MODEL_RETRY listener record it via updateDisplay({ retryDelayMs }, updateOutput) instead of the empty patch, forward it in outputUpdateHandler alongside waitingForExternalInput/awaitingApproval, and in onToolHeartbeat's executing branch arm the tool deadline as TOOL_PROGRESS_TIMEOUT_MS + Math.min(event.retryDelayMs ?? 0, MAX_RETRY_DEADLINE_EXTENSION_MS) (clearing it from the display on the next non-retry progress event so it cannot stick).

Any fix must respect this: MAX_RETRY_DEADLINE_EXTENSION_MS = 6 * 60 * 60_000 (agent-progress-watchdog.ts:20) already bounds the model-side extension and keeps the resulting setTimeout under Node's 2^31-1 overflow clamp, so any propagated tool-side extension must use the same clamp — retryWithBackoff reports an unclamped provider Retry-After (retry.ts:496-500), and 10 * 60_000 plus an unclamped value can exceed 2147483647 ms, which Node fires immediately instead of late.

Please add the test that pins this: in agent-progress-watchdog.test.ts, a case that emits TOOL_CALL + TOOL_PROGRESS{retryDelayMs: 20 * 60_000}, advances 11 minutes and asserts the signal is not aborted, then advances past the extended deadline and asserts phase === 'tool'; removing the propagation must make the 11-minute assertion fail. Plus an agent.test.ts case asserting a child MODEL_RETRY produces an updateOutput call whose display carries the delay (today it produces {}).

中文说明

[Critical] R6-3:新增的“重试退避延长期限”机制无法跨越嵌套边界 —— 嵌套运行的 MODEL_RETRY 被降级成一次不带标记的 forwardProgress 心跳,于是被监控的后台 Agent 的工具期限仍固定在 10 分钟,而嵌套运行正在按 provider 指定的退避时长休眠。

后台 Agent B(watchdog 挂在 bgEmitter 上,agent.ts:3824)以前台方式派生嵌套 Agent C,因此 setupEventListeners(updateOutput, sessionWorkflowAgent, runtimeEventEmitter)agent.ts:3123)是 C 向 B 的调度器汇报进度的唯一通道。C 的模型请求遇到 429,其 Retry-After 为 3600 秒。retryWithBackoff 有意不做钳制地保留它(actualDelayMs = retryAfterMspackages/core/src/utils/retry.ts:496-500),并在休眠之前触发一次 onRetry。C 自己的 emitter 收到一次 MODEL_RETRY —— 如果 C 是被监控方,agent-core.ts:1000 会把它变成 15 分钟 + delay 的延长;但在这里它只到达 forwardProgress,后者调用 updateDisplay({}, updateOutput)。B 的 outputUpdateHandleragent-core.ts:1977-1992)随后为 C 的 callId 发出 TOOL_PROGRESS,而 AgentToolProgressEventagent-events.ts:181-194没有 delay 字段,所以 B 的 watchdog 走 executing 分支,重新武装的正好是 TOOL_PROGRESS_TIMEOUT_MS = 10 * 60_000。休眠期间没有别的事件发出(persistent 模式的 heartbeatFn 写的是 stderr,不是 emitter),于是在一次正当限流等待进行到 10 分钟时,B 被以 Background agent tool "agent" made no progress for 600000ms 中止,C 通过子 abort controller 被杀掉,registry 把 B 结算为 failed —— 对一个正在遵守 provider 指定等待的运行写下了错误的终态记录,而这恰恰是 onRetry 这条管线被加进来要覆盖的重试路径。加重情节:forwardProgress 有 1 秒节流,因此若 MODEL_RETRY 在任何其他被转发事件的 1 秒内到达,它会被完全丢弃,父级的计时会继续从上一次心跳算起。

证据(探针,源码未修改,6/6 通过)见上方英文代码块:P5 与 P6 合起来正是这个不对称 —— 同样一小时的 provider 等待,由被监控 Agent 上报时可换来 75 分钟,由嵌套运行上报时只有 10 分钟;而且不带标记的心跳还会执行 clearModel():198),即移除父级原本已有的模型期限。触发现实性:仓库自己的测试就把 10 分钟的 provider 等待钉为必须尊重的场景 —— retry.test.ts:1344“should respect oversized Retry-After values for normal retries”对 retry-after: '600' 断言 setTimeout 延迟为 600_000 —— 而这正好等于 TOOL_PROGRESS_TIMEOUT_MS。嵌套运行中任何高于该值的 Retry-After 都会中止后台 Agent。baseline: new-surface:watchdog、MODEL_RETRY 与整条中止路径都由本次提交新增。

修复建议:给这条传播通道带上 delay。为 AgentToolProgressEvent 和嵌套的 task_execution display 增加 retryDelayMs?: number;让 C 的 MODEL_RETRY 监听器通过 updateDisplay({ retryDelayMs }, updateOutput) 记录它,而不是发一个空 patch;在 outputUpdateHandler 中与 waitingForExternalInput/awaitingApproval 一起转发;并在 onToolHeartbeat 的 executing 分支中把工具期限武装为 TOOL_PROGRESS_TIMEOUT_MS + Math.min(event.retryDelayMs ?? 0, MAX_RETRY_DEADLINE_EXTENSION_MS)(并在下一个非重试进度事件时从 display 中清除,避免它粘住)。

修复必须遵守:MAX_RETRY_DEADLINE_EXTENSION_MS = 6 * 60 * 60_000agent-progress-watchdog.ts:20)已经在约束模型侧的延长,并使最终的 setTimeout 保持在 Node 的 2^31-1 溢出钳位之下,因此任何传播到工具侧的延长都必须使用同一个钳位 —— retryWithBackoff 上报的是未钳制的 provider Retry-Afterretry.ts:496-500),而 10 * 60_000 加上一个未钳制的值可能超过 2147483647 毫秒,此时 Node 会立即触发而不是延后触发。

请补上能钉住这一点的测试:在 agent-progress-watchdog.test.ts 中增加一个用例,发出 TOOL_CALL + TOOL_PROGRESS{retryDelayMs: 20 * 60_000},推进 11 分钟并断言信号被中止,再推进超过延长后的期限并断言 phase === 'tool';移除该传播机制时,11 分钟处的断言必须失败。另在 agent.test.ts 中增加一个用例,断言子级 MODEL_RETRY 会产生一次 display 携带该 delay 的 updateOutput 调用(目前它产生的是 {})。

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

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.

Not fixing — escalating. This is one of three findings (R6-1, R6-2, R6-3) that all change watchdog/abort semantics, which is the human-gated class on a PR whose entire subject is timeout semantics. Verified at head:

  • agent.ts:1483eventEmitter.on(AgentEventType.MODEL_RETRY, forwardProgress), so a nested run's retry event reaches the parent through the same handler as STREAM_TEXT and TOOL_PROGRESS.
  • forwardProgress throttles to 1/s (agent.ts:1478) and calls this.updateDisplay({}, updateOutput) (:1480) with an empty object, so any delay the event carried is dropped.
  • AgentToolProgressEvent (agent-events.ts:181) carries settled? (:186), waitingForExternalInput? (:189) and awaitingApproval? (:192) — and no delay or retry field. So the parent watchdog has nothing to extend from and re-arms TOOL_PROGRESS_TIMEOUT_MS = 10 * 60_000 (agent-progress-watchdog.ts:18).

The asymmetry the report describes is therefore structural, not speculative: the same provider-directed wait buys 15 min + delay (capped at MAX_RETRY_DEADLINE_EXTENSION_MS, :20) when the watched agent reports it via agent-core.ts:1000, and a flat 10 minutes when a nested run reports it.

The gate condition: crossing the nesting boundary needs a new field on a shared event type — exactly how awaitingApproval? was added for the approval case — which is a change to the agent event contract, plus a change to what forwardProgress forwards past its 1/s throttle. The decision underneath it is whether a nested run's provider-directed backoff should extend the parent's tool deadline at all, and if so whether the throttle may swallow it. Both are timeout-semantics and data-structure decisions on the mechanism this PR introduces.

Leaving unresolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-3: [certifies-falsely] [new-surface] The tool deadline has no provider-backoff extension, so a nested subagent honouring a long Retry-After gets the whole background turn aborted at 10 minutes — the exact false kill that armModel(retryDelayMs) was added to prevent one level up.

Failure scenario: A background agent spawns a foreground subagent, so the nested run is one tool call under the parent's watchdog. The provider answers the nested model call with 429 and Retry-After: 3600. retry.ts:433 sets delayMs = Math.min(3600000, capMs) = 3600000 (and the non-persistent path at retry.ts:496, actualDelayMs = retryAfterMs, is uncapped); onRetry fires exactly once before the sleep (retry.ts:462). That produces one nested MODEL_RETRY, which forwardProgress (agent.ts:1494) turns into one parent TOOL_PROGRESS heartbeat — resetting the parent's tool deadline to a flat 10 minutes and discarding the delay, because armTool takes no extension argument. Nothing else is emitted during the hour-long sleep, so at 10 minutes abort(new AgentProgressTimeoutError('tool', 600000, 'Agent')) fires on the turn controller and 5 s later registry.failUnresponsive(agentId, ...). The subagent is killed mid-legitimate-backoff and the background agent is marked failed, while the identical wait at the top level survives because armModel grants 15min + min(3600000, MAX_RETRY_DEADLINE_EXTENSION_MS). The author aligned that cap to PERSISTENT_CAP_MS (both 6 h), so a wait of up to 6 hours is explicitly considered legitimate — the tool path just never learns about it.

Witness:

sweep of the real source — `retryDelayMs` occurs in `packages/core/src/agents/runtime/` at exactly 4 watchdog/producer sites on the **model** path (`agent-progress-watchdog.ts:99, 111, 122, 158`) and **0** on the tool path (`armTool` `:125-141`, `onToolHeartbeat` `:168-206`); `AgentToolProgressEvent` declares no such field. Supporting run (non-discriminating — it does not exercise the nested case): `npx vitest run src/agents/runtime/agent-progress-watchdog.test.ts` → `Tests 7 passed (7)`, i.e. the suite simultaneously proves the model side survives a 1 h retry delay (`keeps a provider-backoff 

Suggested fix: Give the tool deadline the same extension the model deadline has. Carry the nested retry delay out on the task_execution display (set it in the MODEL_RETRY handler next to forwardProgress, clear it on ROUND_START/STREAM_TEXT), surface it as an optional retryDelayMs on AgentToolProgressEvent in agent-core.ts's outputUpdateHandler, and in armTool schedule TOOL_PROGRESS_TIMEOUT_MS + Math.min(event.retryDelayMs ?? 0, MAX_RETRY_DEADLINE_EXTENSION_MS), re-arming through a closure that preserves the granted delay exactly as armModel does.

The fix must not violate this existing fact: The tool-side extension must stay within the same bound the model side uses — const MAX_RETRY_DEADLINE_EXTENSION_MS = 6 * 60 * 60_000; (packages/core/src/agents/runtime/agent-progress-watchdog.ts:19) — which is deliberately equal to const PERSISTENT_CAP_MS = 6 * 60 * 60 * 1000; // 6 hours — absolute single wait cap (packages/core/src/utils/retry.ts:25). A smaller cap re-int

Acceptance criterion: packages/core/src/agents/runtime/agent-progress-watchdog.test.ts — a case mirroring the existing 'keeps a provider-backoff deadline extension across a clock-drift re-arm': toolCall('t1'); toolProgress('t1'); toolProgress('t1', { retryDelayMs: 30 * 60_000 }), then assert abortPhase() is still undefined at TOOL_TIMEOUT_MS + 1 and becomes 'tool' at `TOOL_TIMEOUT_MS + Please confirm the mutation that proves it (remove the guard, run that test, confirm it goes red).

中文说明

工具截止时间没有 provider 退避延期,而模型截止时间有。后台 Agent 派生的前台子 Agent 收到 429 Retry-After: 3600 时,只会向父级发出一次无标记的 TOOL_PROGRESS 心跳,把父级工具截止时间重置为固定 10 分钟并丢弃该延迟;随后一小时内再无事件,10 分钟时整个后台轮次被中止。作者已把模型侧上限对齐到 PERSISTENT_CAP_MS(同为 6 小时),说明长达 6 小时的等待被视为合法——工具侧只是从未得知这一点。

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

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.

Real bug: line 1494 forwards MODEL_RETRY via forwardProgress, which drops retryDelayMs, so the top-level watchdog never sees a nested run backoff. Two blockers: (1) there is no transport from the nested AgentCore retry delay to the top-level watchdog — the existing outputChunk channel only carries waitingForExternalInput/awaitingApproval; (2) armModel(retryDelayMs) extends the model deadline, which is suppressed while the nested agent tool is executing, so the active deadline is the 10-minute tool deadline, which armModel does not touch. Extending the tool deadline on a nested retry is a cross-layer design change. Left unresolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-3: [certifies-falsely] [new-surface] Still stands — the retry-backoff deadline extension cannot cross a nesting boundary: a nested run's MODEL_RETRY is downgraded to a flagless forwardProgress heartbeat, so the watched background agent's tool deadline stays at the fixed 10 minutes while the nested run sleeps out a provider-directed backoff

A background agent spawns a foreground subagent, so the nested run is one tool call with a 10-minute tool deadline. The nested model call gets a 429/503 whose Retry-After is honoured uncapped by retryWithBackoff; onRetry fires MODEL_RETRY{retryDelayMs}, but agent.ts forwards it through forwardProgress → updateDisplay({}) which carries no delay, and AgentToolProgressEvent has no retryDelayMs field, so armTool() can only re-arm the fixed TOOL_PROGRESS_TIMEOUT_MS. Ten minutes into a legitimate 30-minute provider wait the parent watchdog aborts the whole background turn and reports it failed — the exact false positive MAX_RETRY_DEADLINE_EXTENSION_MS exists to prevent one level up.

Witness:

not run — scratch-tree refused (available: false); settled by quoted lines at HEAD 90e2d6f7ae: armTool(callId) takes no delay parameter and schedules the bare TOOL_PROGRESS_TIMEOUT_MS (agent-progress-watchdog.ts:122-141), onToolHeartbeat's plain branch is the only caller (agent-progress-watchdog.ts:201-203), and agent.ts:1494-1503 forwards MODEL_RETRY through the 1/s-throttled forwardProgress, which calls updateDisplay({}, updateOutput) with no delay field. Re-derived independently this round by two reverse-audit agents.

Suggested fix:
Carry the delay across the boundary: add retryDelayMs?: number to AgentToolProgressEvent, latch the last MODEL_RETRY delay on the child display, forward it in agent-core's outputUpdateHandler, and give armTool the same Math.min(retryDelayMs, MAX_RETRY_DEADLINE_EXTENSION_MS) extension armModel applies.

The fix must not violate this existing fact: MAX_RETRY_DEADLINE_EXTENSION_MS = 6 * 60 * 60_000 (agent-progress-watchdog.ts:20) is deliberately equal to PERSISTENT_CAP_MS = 6 * 60 * 60 * 1000 (utils/retry.ts:25); the tool-side extension must clamp to the same bound and must be re-passed through the drift re-arm closure exactly as armModel does, or a clock-drift re-arm collapses the deadline back to the base timeout.

Please confirm the mutation that proves it — packages/core/src/agents/runtime/agent-progress-watchdog.test.ts — mirroring the existing MODEL_RETRY-extension case: toolCall, toolProgress carrying retryDelayMs, assert no abort at TOOL_PROGRESS_TIMEOUT_MS + 1 and an abort at TOOL_PROGRESS_TIMEOUT_MS + retryDelayMs. Red if the tool-side extension is removed.

中文说明

重试退避的截止时间延长无法跨越嵌套边界。子运行的 MODEL_RETRYforwardProgress 降级为一次不带标志位的显示心跳(agent.ts:1494-1503,1 秒节流后调用 updateDisplay({}, updateOutput)),而 AgentToolProgressEvent 没有 retryDelayMs 字段,于是 armTool() 只能重新装配固定的 TOOL_PROGRESS_TIMEOUT_MS

后果:后台 Agent 派生一个前台子 Agent(嵌套运行因此只是一个 10 分钟工具截止时间的工具调用)。当子运行的模型调用收到 429/503 且 Retry-AfterretryWithBackoff 不设上限地遵守时,父看门狗会在这次合法的 30 分钟等待进行到第 10 分钟时中止整个后台轮次并把它报为 failed——正是 MAX_RETRY_DEADLINE_EXTENSION_MS 在上一层要防止的误杀。

修复方向:把延迟带过边界——为 AgentToolProgressEvent 增加 retryDelayMs?: number,在子显示上锁存最近一次 MODEL_RETRY 延迟,经 agent-coreoutputUpdateHandler 转发,并让 armTool 采用与 armModel 相同的 Math.min(retryDelayMs, MAX_RETRY_DEADLINE_EXTENSION_MS) 延长。

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

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.

Design-level — left open deliberately. The only fix is a new retryDelayMs field on the cross-package AgentToolProgressEvent transport so a nested run's provider-directed backoff can extend the watched agent's tool deadline across the nesting boundary. Adding new cross-package transport to close one thread is out of scope for this round; it needs its own change with the transport contract reviewed on its own merits. Evidence anchor: packages/core/src/tools/agent/agent.ts:1502.

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.

Re-verified at the post-merge head 93eef769a7 (last answered against 90e2d6f7ae). The nesting boundary holds at this head:

  • packages/core/src/tools/agent/agent.ts:1484eventEmitter.on(AgentEventType.MODEL_RETRY, forwardProgress), and forwardProgress (:1477-1481) ignores the event payload and 1/s-throttles updateDisplay({}, ...).
  • packages/core/src/agents/runtime/agent-events.ts:181-194AgentToolProgressEvent carries only settled / waitingForExternalInput / awaitingApproval.
  • packages/core/src/agents/runtime/agent-core.ts:2094-2109outputUpdateHandler forwards only those two flags into TOOL_PROGRESS.
  • agent-progress-watchdog.ts:128armTool(callId) arms the bare TOOL_PROGRESS_TIMEOUT_MS (10 min); only armModel (:114) accepts a delay.

So the fix is a new field on the runtime event transport, plus a latch/clear rule (a nested MODEL_RETRY delay must be cleared by the next non-retry progress event or it sticks), plus reuse of the MAX_RETRY_DEADLINE_EXTENSION_MS clamp so the resulting setTimeout stays under the 2^31-1 overflow clamp: agent-events.ts + agent.ts + agent-core.ts + agent-progress-watchdog.ts, two layers, one new cross-layer contract.

That is a transport-contract change, not the guard correction this round is scoped to. Leaving unresolved; it needs its own review on the transport contract's merits.

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.

Round jmtz4tb6u1d independent re-verification at head 93eef769a7. REAL, and human-gated. One correction that makes the loss stronger than previously stated on this thread:

forwardProgress is declared zero-arity -- const forwardProgress = () => { (agent.ts:1477) -- and is registered for three event types (:1483-1485, MODEL_RETRY among them). The nested run's retryDelayMs is therefore dropped by signature, before the 1/s throttle at :1479 is ever reached; the throttle is a second, independent loss stacked on top of it. No payload-preserving variant of the existing handler can carry the delay, so this is not a "pass the event through" fix.

The rest confirms at this head:

  • agent-events.ts:181-194 -- AgentToolProgressEvent carries only settled (:186), waitingForExternalInput (:189) and awaitingApproval (:192). No delay field.
  • agent-progress-watchdog.ts:128 -- armTool(callId) takes no delay and schedules the bare TOOL_PROGRESS_TIMEOUT_MS; only armModel (:99) accepts one.
  • agent-progress-watchdog.ts:142 -- armTool's drift re-arm re-passes the bare armTool(callId), so any tool-side extension must be threaded through a closure exactly as armModel does at :125, or a clock-drift re-arm collapses the deadline back to the base timeout.

Gate condition hit: the fix adds a field to the runtime event transport (the same way awaitingApproval? was added for the approval case), plus a latch/clear rule so a nested retry delay cannot stick past the next non-retry progress event, plus reuse of the MAX_RETRY_DEADLINE_EXTENSION_MS clamp -- agent-events.ts + agent.ts + agent-core.ts + agent-progress-watchdog.ts, two layers, one new cross-layer contract. That is outside this round's scope ceiling (no new cross-package field/transport to close a single thread) and needs review on the transport contract's own merits.

No code change this round. Leaving unresolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-3: [certifies-falsely] [new-surface] Still stands, and this round traced a second consequence of the same root — because forwardProgress republishes the merged display and contributes nothing from the event it is bound to, neither the retry-backoff extension nor the nested park flags cross a nesting boundary. The watched ancestor keeps a flat 10-minute tool deadline over a nested run that is legitimately sleeping out a provider-directed backoff or waiting on a human.

Consequence 1, retry: background agent A launches nested B; B's provider returns 429 with a Retry-After above 10 minutes. retry.ts fires onRetry once before sleeping in sleepWithHeartbeat, whose heartbeatFn only writes to stderr and emits no agent event — so B's own watchdog calls armModel(retryDelayMs) and extends, but the parent sees a flagless TOOL_PROGRESS and arms a flat TOOL_PROGRESS_TIMEOUT_MS, then aborts A's turn at 10 minutes. The coverage boundary is asymmetric: a single server-directed wait is capped only at PERSISTENT_CAP_MS (6 h) on the persistent branch and is uncapped on the normal branch, so the direct case is covered up to MAX_RETRY_DEADLINE_EXTENSION_MS while everything above 600 s across a nesting boundary is uncovered.

Consequence 2, park: the parent watchdog's only nested-approval suspension path is onToolHeartbeat's if (event.awaitingApproval) branch — onApproval cannot fire, because a nested approval is never emitted on the watched agent's own emitter for a callId in its tools map. Since the forwarded event contributes nothing, a park two or more levels below a watched background agent never suspends anything and armTool keeps a 10-minute deadline running over a human being asked. DEFAULT_MAX_SUBAGENT_DEPTH = 5 makes that depth reachable with default configuration. This contradicts the design doc's "The relevant tool deadline is suspended while user approval is pending" and the watchdog's own comment that approval waits must not cause false watchdog failures.

R3-12 already reports the code fact (forwardProgress merges zero fields) as a Suggestion. It is filed here separately because the consequence changes its weight: those dropped fields are the ancestor watchdog's only signal for a nested park or a nested backoff, so the same line produces a false kill of a healthy run rather than a stale display.

Confidence is qualified: both mechanisms are settled by run, but the retry trigger needs a provider-directed Retry-After above 600 s on a nested run inside a watched background agent, which was not produced.

Witness:

probe in the PR's own createInvocationWithEventDrivenAgent harness:
intact   -> {"statuses":["c1:executing"]}                       (no park flag on any heartbeat)
one-line candidate fix merging the event's flags into forwardProgress
         -> {"awaitingApproval":true,"statuses":["c1:executing"]} and {"waitingForExternalInput":true,...}   <- the flip
retry arm (parent watching a nested run in backoff, MODEL_RETRY carrying retryDelayMs)
         -> {"aborted":true,"phase":"tool","unresponsive":["Background agent tool \"task\" made no progress for 600000ms."]}
structural: AgentToolProgressEvent has no delay field (agent-events.ts:181-195), so armTool can only ever schedule 10 * 60_000

Give forwardProgress the event it is bound to and merge the fields the ancestor watchdog needs — awaitingApproval, waitingForExternalInput, and a retry-delay field on AgentToolProgressEvent that armTool can extend by, mirroring armModel(retryDelayMs) under the same bounded-extension policy. Propagate recursively so a park or a backoff at any depth reaches the watched ancestor.

Any fix must respect utils/retry.ts:501actualDelayMs = retryAfterMs, under the comment that normal HTTP retries intentionally preserve provider-directed Retry-After waits instead of clamping to the exponential maxDelayMs: a new bound must keep treating that wait as legitimate backoff, and must respect retry.ts:25 PERSISTENT_CAP_MS = 6 * 60 * 60 * 1000 plus the existing Math.min(..., 2_147_483_647) clamp so a 6-hour Retry-After cannot become a setTimeout overflow.

Please add the tests that pin this: one driving a nested run two levels below a watched background agent into an approval wait and asserting the ancestor's tool deadline is suspended, and one asserting a nested MODEL_RETRY carrying retryDelayMs extends the ancestor's tool deadline. Both red while forwardProgress merges zero fields.

中文说明

[Critical] R6-3:依然存在,并且本轮追溯到同一根因的第二个后果——由于 forwardProgress 只是重新发布合并后的 display,完全不贡献它所绑定的事件内容,重试退避的期限延长嵌套的 park 标志都无法跨越嵌套边界。被监视的祖先 Agent 会对一个正在合理等待 provider 退避、或正在等待人工批准的嵌套 run,维持固定的 10 分钟工具期限。

后果一(重试):后台 Agent A 启动嵌套的 B;B 的 provider 返回带 Retry-After(超过 10 分钟)的 429。retry.ts 会在 sleepWithHeartbeat 睡眠之前触发一次 onRetry,而其 heartbeatFn 只向 stderr 写入、不发出任何 agent 事件——因此 B 自己的 watchdog 会调用 armModel(retryDelayMs) 并延长期限,但父级只看到一个无标志的 TOOL_PROGRESS,于是启动固定的 TOOL_PROGRESS_TIMEOUT_MS,并在 10 分钟时中止 A 的这一轮。覆盖边界是不对称的:单次服务端指定的等待在 persistent 分支上只受 PERSISTENT_CAP_MS(6 小时)限制,在 normal 分支上则完全不设上限,因此直连场景可覆盖到 MAX_RETRY_DEADLINE_EXTENSION_MS,而跨嵌套边界超过 600 秒的部分完全无覆盖。

后果二(park):父级 watchdog 唯一的嵌套批准挂起路径是 onToolHeartbeatif (event.awaitingApproval) 分支——onApproval 不可能触发,因为嵌套的批准不会以属于其 tools 映射的 callId 在被监视 Agent 自己的 emitter 上发出。由于转发的事件不贡献任何内容,位于被监视后台 Agent 下方两层或更深的 park 永远不会挂起任何期限,armTool 会在有人正在被征询时继续跑满 10 分钟期限。DEFAULT_MAX_SUBAGENT_DEPTH = 5 使该深度在默认配置下即可达到。这与设计文档的 "The relevant tool deadline is suspended while user approval is pending" 以及 watchdog 自身"批准等待不得导致误报"的注释相矛盾。

R3-12 已经把这一代码事实(forwardProgress 合并零字段)作为 Suggestion 报告过。此处单独立项是因为后果改变了它的分量:这些被丢弃的字段是祖先 watchdog 关于嵌套 park 或嵌套退避的唯一信号,因此同一行代码造成的是对健康 run 的误杀,而不只是 display 陈旧。

置信度有所限定:两个机制均已由运行验证,但重试触发需要在被监视后台 Agent 内部的嵌套 run 上出现超过 600 秒的服务端 Retry-After,该条件未被实际构造。

修复方向:把 forwardProgress 所绑定的事件交给它,并合并祖先 watchdog 需要的字段——awaitingApprovalwaitingForExternalInput,以及在 AgentToolProgressEvent 上增加一个重试延迟字段供 armTool 据此延长,与 armModel(retryDelayMs) 采用相同的有界延长策略。并递归传播,使任意深度的 park 或退避都能抵达被监视的祖先。

修复约束:必须遵守 utils/retry.ts:501actualDelayMs = retryAfterMs,其注释说明普通 HTTP 重试有意保留 provider 指定的 Retry-After 而不收敛到指数退避的 maxDelayMs:新的上界必须继续把该等待视为合法退避,并遵守 retry.ts:25PERSISTENT_CAP_MS = 6 * 60 * 60 * 1000 以及现有的 Math.min(..., 2_147_483_647) 收敛,使 6 小时的 Retry-After 不会造成 setTimeout 溢出。

请补充测试:一个驱动位于被监视后台 Agent 下方两层的嵌套 run 进入批准等待,并断言祖先的工具期限被挂起;另一个断言携带 retryDelayMs 的嵌套 MODEL_RETRY 会延长祖先的工具期限。当 forwardProgress 仍合并零字段时,两者都必须为红。

(证据见上方 Witness 代码块;witness 为程序输出,未翻译。)

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

Comment thread packages/acp-bridge/src/bridge.ts
Comment on lines +456 to +457
/** Reports retry backoff so background-agent liveness can extend its deadline. */
onRetry?: (delayMs: number) => void;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-6: [certifies-falsely] [regression] The new retry-delay reporting stops at makeApiCallAndProcessStream, so the context-compression side query that runs inside the same awaited sendMessageStream retries invisibly — and the 15-minute model deadline kills a background agent that is legitimately waiting for provider capacity in the very mode documented for background automation.

With QWEN_CODE_UNATTENDED_RETRY=1 (retry.ts:154-157) — the mode docs/users/configuration/settings.md:815 describes as "Designed for CI/CD pipelines and background automation where long-running tasks should survive temporary API outages" — a long-running background agent's round crosses the compaction threshold, so sendMessageStream awaits tryCompress (llm-chat.ts:2864, or the reactive call at :3664) → ChatCompressionServiceconfig.getBaseLlmClient().generateText(...)retryWithBackoff with persistentMode: isUnattendedMode() (baseLlmClient.ts:300) whose onRetry is wired only to logApiRetry (baseLlmClient.ts:307-320, :462). GenerateTextOptions exposes no onRetry field, so no caller could surface the delay even if it wanted to. The compression call's maxAttempts: 1 does not bound that wait: shouldPersist = persistent && isTransient && callerAllowsRetry && !isFailFast (retry.ts:412-413) ignores maxAttempts, and the persistent branch clamps attempt so "the while-loop never exits" (retry.ts:485-487), sleeping in heartbeat chunks on a 429/529. Meanwhile the watchdog's model timer sees nothing at all — the compression phase emits no AgentEvent, and StreamEventType.COMPRESSED is only logged (agent-core.ts:1084-1092, continue) and is not in the listener set. At 15 minutes abort(new AgentProgressTimeoutError('model/control', 900000)) fires, registry.failUnresponsive settles the agent permanently as failed with "made no model/control progress for 900000ms", the user gets a failure notification for an agent doing exactly what unattended mode promises, and this diff's recycle path condemns the runtime generation. The identical wait on the round's own model call would have been extended by up to 6 h. This falsifies the diff's own contract in docs/design/background-agent-progress-watchdog.md: "Retry delays surfaced by qwen-code extend the model deadline by at most six hours" — a compression retry is a qwen-code retry, not provider-internal; it is simply never surfaced.

Tighter than it looks: sendMessageStream returns its generator only at llm-chat.ts:3111, so the proactive tryCompress at :2864 runs in the eager prologue — during await chat.sendMessageStream(...), i.e. before agent-core emits ROUND_START (:1009). The budget charged to the compression wait is therefore the timer armed by the previous round's last event, or the attach-time arm on round 1 — not a freshly re-armed 15 minutes.

Witness — not run: the nearest capability was mock-provider + drive (429 the compression side query with a long Retry-After under QWEN_CODE_UNATTENDED_RETRY=1 and observe the abort). It cannot reach the outcome here: MODEL_CONTROL_PROGRESS_TIMEOUT_MS = 15 * 60_000 is a hardcoded module constant with no env or config knob (the design doc states the change "adds no public timeout configuration"), so a faithful drive needs 15 minutes of real wall time, and faking the clock would require an integration harness spanning AgentCore + LlmChat + ChatCompressionService that does not exist in the repo (no test attaches the watchdog to a real run). The chain was verified link by link at HEAD instead, and the reporting sweep below is run-produced:

grep "onRetry:" packages/core/src  (non-test) -> 5 sites
  agent-core.ts:1000      <- the only producer that reaches the watchdog
  llm-chat.ts:4654        <- the new forwarder added by this diff
  baseLlmClient.ts:307    <- logApiRetry only
  baseLlmClient.ts:462    <- logApiRetry only
  client.ts:5055          <- logApiRetry only

baseline: regressionagent-progress-watchdog.ts is a new file in this diff and is the only source of the 15-minute abort; at the merge base nothing aborted a background turn for lack of model progress, so an unattended compression capacity-wait survived (bounded only by the caller's own wall-clock limit). Precondition, stated plainly: the long wait needs QWEN_CODE_UNATTENDED_RETRY=1 and a 429/529 on the compression side query whose cumulative wait exceeds the remaining model budget (one Retry-After > 15 min suffices; otherwise ~3 backoff retries at the 5-min cap). Without that env var, maxAttempts: 1 fails the compression immediately to NOOP and there is no long wait.

Suggested fix: thread the send option down the compression path rather than widening its retry budget — add onRetry?: (delayMs: number) => void to TryCompressOptions (llm-chat.ts:481) and pass options?.onRetry at both tryCompress call sites (:2864, :3664); carry it in ChatCompressionService's opts next to the existing signal (chatCompressionService.ts:284) and forward it into both generateText calls, having BaseLlmClient invoke a caller-supplied onRetry alongside logApiRetry (baseLlmClient.ts:307, :462). Then state in both design docs which retry loops report.

Any fix must respect this: chatCompressionService.ts:749-751 — "Best-effort: failures fall back to NOOP and the next turn re-triggers compression anyway, so don't burn 7 retries blocking the user mid-turn." with maxAttempts: 1; a fix must report the delay, not raise compression's retry budget.

Please add the test that pins this: in packages/core/src/core/llm-chat.test.ts, a case that forces compression inside sendMessageStream with a stubbed Config.getBaseLlmClient().generateText whose retryWithBackoff reports one retry, and asserts the onRetry supplied in LlmChatSendOptions was invoked with that delay. Deleting the new forwarding must turn it red; today nothing in the repo observes compression retry reporting.

中文说明

[Critical] R6-6:新增的重试延迟上报止步于 makeApiCallAndProcessStream,因此在同一个 await 的 sendMessageStream 内部运行的上下文压缩旁路查询,其重试是不可见的 —— 而 15 分钟模型期限会杀掉一个正当等待 provider 容量的后台 Agent,且发生在那个专为后台自动化而记录的运行模式下。

QWEN_CODE_UNATTENDED_RETRY=1retry.ts:154-157)下 —— docs/users/configuration/settings.md:815 把该模式描述为“为 CI/CD 流水线与后台自动化设计,让长时间运行的任务能够挺过临时性 API 中断” —— 一个长时间运行的后台 Agent 的某轮越过压缩阈值,于是 sendMessageStream 会 await tryCompressllm-chat.ts:2864,或 :3664 的反应式调用)→ ChatCompressionServiceconfig.getBaseLlmClient().generateText(...)retryWithBackoff,其 persistentMode: isUnattendedMode()baseLlmClient.ts:300),而它的 onRetry 接到了 logApiRetrybaseLlmClient.ts:307-320:462)。GenerateTextOptions 不暴露任何 onRetry 字段,因此即便调用方想上报也无从下手。压缩调用的 maxAttempts: 1不能约束这段等待:shouldPersist = persistent && isTransient && callerAllowsRetry && !isFailFastretry.ts:412-413)完全忽略 maxAttempts,且 persistent 分支会钳制 attempt 以致“while 循环永不退出”(retry.ts:485-487),在 429/529 上以心跳分片方式持续休眠。与此同时 watchdog 的模型定时器什么都收不到 —— 压缩阶段不发出任何 AgentEvent,而 StreamEventType.COMPRESSED 只被记日志(agent-core.ts:1084-1092,随后 continue),并不在监听集合内。到 15 分钟时 abort(new AgentProgressTimeoutError('model/control', 900000)) 触发,registry.failUnresponsive 把该 Agent 永久结算为 failed,理由是“made no model/control progress for 900000ms”,用户收到一个“Agent 失败”的通知,而这个 Agent 做的恰恰是 unattended 模式所承诺的事;本 diff 的回收路径还会判该 runtime generation 死刑。同样这段等待如果发生在本轮自己的模型调用上,是被延长最多 6 小时的。这与 diff 自己在 docs/design/background-agent-progress-watchdog.md 写下的契约相矛盾:“由 qwen-code 上报的重试延迟最多把模型期限延长六小时” —— 压缩重试是 qwen-code 的重试,不是 provider 内部的;它只是从未被上报。

比看起来更紧:sendMessageStream 直到 llm-chat.ts:3111 才返回其 generator,因此 :2864 处主动触发的 tryCompress 运行在** eagerly 前置段**中 —— 也就是在 await chat.sendMessageStream(...) 期间、在 agent-core 发出 ROUND_START:1009之前。所以计入压缩等待的预算,是上一轮最后一个事件武装的定时器,或者第 1 轮时挂载即武装的那个 —— 而不是一个刚刚重新武装的 15 分钟。

证据 —— not run:最接近的手段是 mock-provider + drive(在 QWEN_CODE_UNATTENDED_RETRY=1 下用一个较长的 Retry-After 让压缩旁路查询返回 429,然后观察中止)。但它无法达到此处要证明的结果:MODEL_CONTROL_PROGRESS_TIMEOUT_MS = 15 * 60_000 是硬编码的模块常量,没有任何 env 或配置开关(设计文档明说本次变更“不新增公开的 timeout 配置”),因此一次忠实的驱动需要 15 分钟真实墙钟时间;而伪造时钟需要一个横跨 AgentCore + LlmChat + ChatCompressionService 的集成测试装置,仓库中并不存在(没有任何测试把 watchdog 挂到真实运行上)。因此改为在 HEAD 上逐环节核实链路,而下面这份上报点清点是由运行产出的:grep "onRetry:" packages/core/src(非测试)→ 5 处,其中只有 agent-core.ts:1000 能到达 watchdog,llm-chat.ts:4654 是本 diff 新增的转发点,其余三处(baseLlmClient.ts:307:462client.ts:5055)只调用 logApiRetry

baseline: regression —— agent-progress-watchdog.ts 在本 diff 中是新文件,也是这个 15 分钟中止的唯一来源;在合并基线上,没有任何逻辑会因为“模型无进展”而中止一个后台 turn,因此一次 unattended 的压缩容量等待是可以挺过去的(只受调用方自己的墙钟上限约束)。前提条件明确写出:这段长等待需要 QWEN_CODE_UNATTENDED_RETRY=1并且压缩旁路查询上出现 429/529 且其累计等待超过剩余的模型预算(一个大于 15 分钟的 Retry-After 就够了;否则约为 5 分钟上限下的 3 次退避重试)。若没有该环境变量,maxAttempts: 1 会让压缩立刻失败并回退为 NOOP,也就没有长等待。

修复建议:把这个 send option 沿压缩路径传下去,而不是放宽它的重试预算 —— 为 TryCompressOptionsllm-chat.ts:481)增加 onRetry?: (delayMs: number) => void,并在两个 tryCompress 调用点(:2864:3664)传入 options?.onRetry;把它与既有的 signal 一起放进 ChatCompressionService 的 opts(chatCompressionService.ts:284)并转发进两个 generateText 调用,让 BaseLlmClientlogApiRetry 之外也调用调用方提供的 onRetrybaseLlmClient.ts:307:462)。随后在两份设计文档中写明哪些重试循环会上报。

修复必须遵守:chatCompressionService.ts:749-751 —— “尽力而为:失败会回退为 NOOP,下一轮反正会重新触发压缩,所以不要为了它烧掉 7 次重试、把用户卡在 turn 中间。”并配有 maxAttempts: 1;修复必须上报该延迟,而不是提高压缩的重试预算。

请补上能钉住这一点的测试:在 packages/core/src/core/llm-chat.test.ts 中增加一个用例,在 sendMessageStream 内部强制触发压缩,并用一个桩替换 Config.getBaseLlmClient().generateText,让其 retryWithBackoff 上报一次重试,然后断言 LlmChatSendOptions 中提供的 onRetry 被以该延迟调用。删除新增的转发时该测试必须变红;目前仓库中没有任何东西在观察压缩重试的上报。

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

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.

Not fixing this one — it needs a maintainer decision. Escalating rather than leaving it silently.

I verified the structural claim at head instead of taking the summary's word for it, and it holds:

  • LlmChatSendOptions.onRetry is the new field (llm-chat.ts:453-458), documented as "Reports retry backoff so background-agent liveness can extend its deadline".
  • It reaches only the round's own model call: makeApiCallAndProcessStream's param (:4593, :4691), invoked at :4655, fed from :3335, :3996, :4435.
  • Neither tryCompress call site passes it (:2864, :3664), and TryCompressOptions (:481 onward) has no onRetry field, so no caller could surface it.
  • baseLlmClient.ts:307 and :462 wire onRetry to logApiRetry only, alongside persistentMode: isUnattendedMode() (:300); GenerateTextOptions exposes no onRetry.
  • agent-core.ts:1000 is the only producer that reaches the watchdog.

So the compression side query's capacity wait really is invisible to the model deadline, while the round's own identical wait extends it by up to MAX_RETRY_DEADLINE_EXTENSION_MS.

Why I am escalating rather than fixing:

  1. The fix changes watchdog deadline semantics, not reporting plumbing. Wiring compression retries into onRetry means a compression 429 now extends the model deadline — it changes when a background agent is aborted and settled failed. On a PR whose entire subject is timeout semantics, that is the human-gated class, not a mechanical defect like the twin guard I did fix.
  2. Blast radius. Three source files plus two design docs (llm-chat.ts, chatCompressionService.ts, baseLlmClient.ts), and it adds a caller-supplied callback to BaseLlmClient.generateText's options — a new field on a widely used core entry point.
  3. The underlying decision is not mine. Should an unattended compression capacity-wait buy the same six-hour extension as the round's own model call, or should compression stay maxAttempts: 1 and fail to NOOP fast? The file argues for the latter at chatCompressionService.ts:749-751 ("don't burn 7 retries blocking the user mid-turn"), and the design doc's contract argues for the former. Those pull in opposite directions, and whichever way it goes changes behaviour for every unattended run.

One correction to offer so the decision is made on accurate footing: the witness here is marked not run, and MODEL_CONTROL_PROGRESS_TIMEOUT_MS is a hardcoded constant with no knob, so nobody has observed this end to end. The chain reads sound, but the trigger needs QWEN_CODE_UNATTENDED_RETRY=1 and a 429/529 on the compression side query whose cumulative wait outlasts the remaining model budget. Worth knowing that this is a read-verified rather than observed failure before deciding how urgently to reshape the compression path.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-6: [certifies-falsely] [new-surface] retainsPhysicalSlot has exactly two release sites — this .finally and its twin at background-agent-resume.ts:1452 — and both require the turn promise to settle, so a watchdog kill of a run that never physically settles retains the slot permanently, and the watchdog has already flipped the entry to failed, which removes the task_stop remedy that previously unblocked the session-switch gate.

Failure scenario: A background agent's tool ignores the abort signal (the case cancel()'s own comment at background-tasks.ts:957-960 names: "the rare case where a tool ignores AbortSignal and bgBody never settles"). The watchdog aborts at the 10 min tool deadline, and 5 s later failUnresponsive sets entry.status = 'failed', entry.notified = true, entry.retainsPhysicalSlot = true (background-tasks.ts:920-931). bgBody never returns, so runBackgroundTurn's .finally never runs and the flag is never deleted. From then on, for the life of the process: (1) hasRunningTasks() returns true via the clause this diff added (background-tasks.ts:1497, diff line 1330), so hasBlockingBackgroundWork() is true and /clear (clearCommand.ts:47), /resume (useResumeCommand.ts:107), /branch (useBranchCommand.ts:112) and the opentui session-switch (session-switch.ts:101,267) all refuse permanently; (2) the refusal message names the blocker as "— still stopping" and tells the user "Use /tasks to inspect them, then retry", but task_stop on that entry hits if (agentEntry.status !== 'running') return notRunningError('agent', taskId, agentEntry.status) (task-stop.ts:74-75) and cancel()/the dialog's cancelSelected hit if (!entry || entry.status !== 'running') return; (background-tasks.ts:969) — both no-ops, so there is no user-reachable clear; (3) pruneTerminalEntries

Witness:

[probe] A1 after failUnresponsive {"status":"failed","retainsPhysicalSlot":true,"notified":true,"inMap":true,"hasRunningTasks":true} notification emitted, meta.recordOnly = true status = failed A2 after cancel() [task_stop / dialog stop] {…retainsPhysicalSlot:true, hasRunningTasks:true} A3 after abandon() {…retainsPhysicalSlot:true, hasRunningTasks:true} A4 after abortAll({notify:false}) {…retainsPhysicalSlot:true, hasRunningTasks:true} A5 after late fail()/complete() {…retainsPhysicalSlot:true, hasRunningTasks:true} A6 af

Suggested fix: Give the retained slot a bounded exit. Either mirror cancel()'s deferred fallback inside failUnresponsive — an unref'd setTimeout that calls releaseRetainedPhysicalSlot(agentId) after a hard grace, so the flag can never outlive a bounded window — or (better, because it restores the user's remedy) let cancel() accept a failed-with-retainsPhysicalSlot entry and force-release: if (entry.retainsPhysicalSlot) { this.releaseRetainedPhysicalSlot(agentId); return; } ahead of the status !== 'running' early return, and drop the matching status !== 'running' refusal in `task-stop.ts

The fix must not violate this existing fact: background-tasks.ts:1489-1491 — "A watchdog-terminal run still counts while its underlying execution holds a physical slot, so session reset cannot erase the only remaining owner." A forced release re-opens the /clear gate, and reset() (background-tasks.ts:1512-1535) clears the map without aborting entry.abortController (unlike the workflow registry, which aborts

Acceptance criterion: packages/core/src/agents/background-tasks.test.ts — a new case that calls failUnresponsive(agentId, …), leaves the turn promise unsettled, then (fake timers) advances past the fallback grace and asserts entry.retainsPhysicalSlot is undefined and registry.hasRunningTasks() is false; equivalently, a task-stop test asserting a force-clear on a failed+retained entry Please confirm the mutation that proves it (remove the guard, run that test, confirm it goes red).

中文说明

retainsPhysicalSlot 只有两个释放点(此处与 background-agent-resume.ts:1452),且都要求轮次 Promise 落定。因此当被看门狗杀死的运行在物理上永不结束时,该槽位永久保留:hasRunningTasks() 持续为真,/clear/new/resume/branch 与会话切换被无限期拒绝,而用户侧没有任何可达的解除手段。

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

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.

Real bug, but the compression side query does not go through makeApiCallAndProcessStream: tryCompress -> ChatCompressionService.compress calls config.getBaseLlmClient().generateText(...) directly (chatCompressionService.ts ~line 855), so the onRetry callback threaded only into makeApiCallAndProcessStream never reaches it. Reporting compression backoff requires either adding an onRetry channel to compress() or changing generateText/the base LLM client, which is cross-cutting. Deferring to a focused pass. Left unresolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R6-6: [certifies-falsely] [regression] Still stands — retainsPhysicalSlot has exactly two release sites and both require the turn promise to settle, so a watchdog kill of a run that never physically settles retains the slot permanently, and the watchdog has already flipped the entry to failed, which removes the task_stop escape

A background agent's tool ignores AbortSignal. The watchdog aborts; 5 s later failUnresponsive sets status=failed, notified=true and retainsPhysicalSlot=true. Because the tool never returns, runBody never settles, so neither .finally release site runs. Every escape is closed: cancel() bails on status !== running, abandon() requires paused, pruneTerminalEntries() explicitly exempts retained entries, and reset() sits behind the very gate that is stuck. For the rest of the process lifetime hasRunningTasks() stays true, so /clear, /resume, /branch and session switch are refused forever with a "— still stopping" row the user cannot act on, and getRunningBackgroundCount() keeps counting the entry, so each hang permanently costs one of the 10 background slots. The daemon path escapes only because Session.ts requests a runtime recycle that kills the child; the interactive TUI has no equivalent.

Witness:

[probe] mutation over the real population: releaseRetainedPhysicalSlot has 3 hits repo-wide (definition background-tasks.ts:943, call sites agent.ts:3934 and background-agent-resume.ts:1452) and 0 in any test; deleting all three retained-slot terms (background-tasks.ts:1332/1497/1876) leaves background-tasks.test.ts green at 151 passed, while the positive control (entry.retainsPhysicalSlot = undefined in failUnresponsive) fails 1 — "retains the physical slot when the watchdog escalates a cancelled agent" — proving the suite reaches the module and pins only the flag, never its consequences.

Suggested fix:
Bound the retention the way the cancel path bounds its slot-holding state: in failUnresponsive arm an unref'd fallback timer that calls releaseRetainedPhysicalSlot(agentId) after a grace window, so a run that never settles cannot hold the gate and a concurrency slot for the process lifetime.

The fix must not violate this existing fact: hasRunningTasks()'s documented contract — "A watchdog-terminal run still counts while its underlying execution holds a physical slot, so session reset cannot erase the only remaining owner." (background-tasks.ts:1489-1493) — so the fix must be a delayed release, not removing entry.retainsPhysicalSlot from that predicate.

Please confirm the mutation that proves it — packages/core/src/agents/background-tasks.test.ts, beside the existing failUnresponsive/retainsPhysicalSlot case: with fake timers, advance past the new grace window while the turn promise stays unsettled and assert hasRunningTasks() === false and that a fresh admission at the cap succeeds. Red if the timer is deleted.

中文说明

retainsPhysicalSlot 只有两个释放点——启动路径的 .finally 与恢复路径的这一个——两者都要求轮次 Promise 落定;而这恰恰是该标志位存在的前提所不成立的情形。看门狗已经先把条目置为 failed,这又移除了 task_stop 这条逃生路径。

后果(交互式 TUI 没有运行时回收可依赖):hasRunningTasks() 永远为真,/clear/resume/branch 与会话切换被永久拒绝,并显示一行用户无法操作的 “— still stopping”;getRunningBackgroundCount() 持续计数,每次挂起都永久消耗 10 个后台并发槽中的一个,直到重启 CLI。cancel()status !== 'running' 提前返回,abandon() 要求 pausedpruneTerminalEntries() 明确豁免保留条目,reset() 又位于被卡住的那道门之后。

修复方向:像取消路径用 CANCEL_GRACE_MS 约束其占槽状态那样约束保留——在 failUnresponsive 中装配一个 unref 的兜底定时器,在宽限窗口后调用 releaseRetainedPhysicalSlot(agentId)

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

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.

Design-level — left open deliberately. Making the compression side-query retry visible to the watchdog requires a retry hook on the shared GenerateTextOptions interface (there is no onRetry today), so the fix is a new option on a cross-cutting interface rather than a guard correction. Out of scope here; it needs to be designed with the other GenerateTextOptions consumers. Evidence anchor: packages/core/src/core/llm-chat.ts:564.

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.

Re-verified at the post-merge head 93eef769a7. The chain holds end to end:

  • LlmChatSendOptions.onRetry (packages/core/src/core/llm-chat.ts:564) reaches only the round's own model call; TryCompressOptions (:588) has no such field, so both tryCompress call sites drop it.
  • The compression path calls config.getBaseLlmClient().generateText(...) directly (packages/core/src/services/chatCompressionService.ts:844, with maxAttempts: 1 at :884), and GenerateTextOptions (packages/core/src/core/baseLlmClient.ts:78) has no onRetry — the hook at :307 is logApiRetry only.

The minimal change set is 3 files (llm-chat.ts, chatCompressionService.ts, baseLlmClient.ts), but it adds a caller-supplied callback to the exported GenerateTextOptions entry point, which other consumers share (packages/core/src/utils/sideQuery.ts:254), and it settles a liveness-policy question that should not be decided in a closeout round: should a compression side query's provider backoff extend a background agent's model deadline at all, or can that mask a genuinely wedged agent? No guard-level fix is available without that decision. Leaving unresolved.

Ledger note: this thread's round-8 body describes a different finding — retainsPhysicalSlot release sites (packages/core/src/agents/background-tasks.ts:935, :943), released only from agent.ts:3916 and background-agent-resume.ts:1437. If that one is the live entry under this id, it has the same shape: a bounded exit for a run that never physically settles is a decision about what the retained concurrency slot means, so it is not a guard fix either.

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.

Round jmtz4tb6u1d independent re-verification at head 93eef769a7. This thread carries two different findings -- the round-6 body is the compression side query, the round-8 body is retainsPhysicalSlot. Both re-derived from source. Both REAL, both human-gated.

(a) Compression retry invisible to the model deadline. Chain confirmed link by link:

  • llm-chat.ts:564 -- onRetry?: (delayMs: number) => void on LlmChatSendOptions (:560).
  • llm-chat.ts:588 -- TryCompressOptions declares no such field, and neither tryCompress call site passes one (:3102, :3965).
  • chatCompressionService.ts:844 -- compression reaches the provider via config.getBaseLlmClient().generateText({...}) with maxAttempts: 1 at :884.
  • baseLlmClient.ts:78 -- GenerateTextOptions exposes no onRetry; the hooks at :307 and :462 are logApiRetry only, sitting next to persistentMode: isUnattendedMode() (:300, :455).

Gate: the minimal change adds a caller-supplied callback to the exported GenerateTextOptions, which other consumers share (utils/sideQuery.ts:254), and it settles a liveness-policy question -- may a compression side query's provider backoff extend a background agent's model deadline, or does that mask a genuinely wedged agent? chatCompressionService.ts:749-751 argues the latter; this PR's design doc argues the former. Not a closeout-round decision.

(b) retainsPhysicalSlot has no bounded exit -- new evidence, and it cuts against the suggested fix. The structural half confirms: failUnresponsive sets the flag at background-tasks.ts:943; the only two release sites are the .finally pair (agent.ts:3916, background-agent-resume.ts:1437); hasRunningTasks() counts it at :1512; pruneTerminalEntries() exempts it at :1890; and cancel() bails on status !== 'running' at :984, which failUnresponsive already flipped to failed at :940. So there is genuinely no user-reachable remedy in the interactive TUI, which has no runtime recycle to fall back on.

But the proposed remediation (an unref'd fallback timer inside failUnresponsive calling releaseRetainedPhysicalSlot) contradicts this PR's own documented invariant at background-tasks.ts:920-934: "Reaching this method at all proves the execution is still alive (the watchdog is detached once it settles), so the physical slot must still be retained ... otherwise getRunningBackgroundCount and hasRunningTasks() free a concurrency slot that is still occupied, and /clear, /resume, /branch and session switches all proceed over live work." A delayed auto-release does exactly that once it fires, and reset() clears the map without aborting entry.abortController, so a still-running orphan loses its only owner. The force-release-in-cancel() variant is the same trade with a human in the loop, and additionally needs the matching status !== 'running' refusal dropped in task-stop.ts.

Gate: choosing between an unrecoverable stuck gate and freeing a slot a live orphan still occupies is concurrency-primitive semantics with a data-loss edge. Needs the maintainer to pick a side, plus the grace bound if the automatic release is chosen.

No code change this round. Leaving unresolved.

Comment on lines +14613 to 14616
const info = admissibleChannelInfo();
if (!info) {
throw Object.assign(
new Error(`No live ACP channel for runtime MCP remove: ${name}`),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-36: [certifies-falsely] [regression] Still stands — the workspace-scoped runtime-MCP routes resolve their target through admissibleChannelInfo(), so during a drain they act on the replacement generation while every live session stays on the draining one. removeRuntimeMcpServer silently no-ops with HTTP 200, and addRuntimeMcpServer broadcasts a workspace-wide event that no existing session can observe.

Carried forward under its original id from the round-1 ledger; re-traced and re-measured at HEAD 6bc80c0df4. Commit d7f26da1b5 ("stop routing workspace mutations to draining generations") moved generateWorkspaceAgent (:14435), addRuntimeMcpServer (:14558) and removeRuntimeMcpServer (:14613) from liveChannelInfo() to admissibleChannelInfo(). But after a recycle marks generation A draining and publishes active B, admissibleChannelInfo() returns B — the generation that owns no sessions — so the incident's step is unchanged.

A background Agent in session S1 ignores abort; Session.ts:9788 requests the recycle → requestRuntimeRecycleForSession sets A to draining, defers its retirement (retireWhenSessionsDrain = true, :3765, because S1/S2 are still attached) and ensureChannel('recovery') spawns B and assigns channelInfo = B (:5220). DELETE /workspaces/:w/mcp/servers/internal-db then resolves to B; B never had internal-db, so the child answers {name, skipped: true, reason: 'not_present'} (the RemoveSkip shape at :14626), no mcp_server_removed event is broadcast, and the route returns HTTP 200 with that skip body (workspace-mcp-control.ts:634-641). internal-db stays connected in generation A — which still owns every live session — and its tools remain callable from S1/S2 for as long as A lives (arbitrarily long: A is retired only when its sessions drain). The symmetric add writes the server into B only, yet broadcasts mcp_server_added to all workspace subscribers, so the Web Shell lists a server that sessions on A cannot call. The session-scoped twins in the same object were not retargeted — addSessionRuntimeMcpServer/removeSessionRuntimeMcpServer route through requestSessionStatus(sessionId, …) (:14658-14678), which resolves the entry's owning generation — so the two scopes now disagree about which child is "the" runtime. This violates issue #8586's Layer-4 acceptance criterion "Runtime draining preserves owner-generation routing".

Witness (probe against the real bridge):

ROW A (remove, after recycle):
  add result = {"name":"internal-db","transport":"stdio",...,"toolCount":1} -> gen1
  generations after recycle = 2
  workspace remove result = {"name":"internal-db","skipped":true,"reason":"not_present"}
  gen1: calls ["workspace/mcp/runtime-add","session/mcp/runtime-remove"] workspaceServers ["internal-db"] killed false
  gen2: calls ["workspace/mcp/runtime-remove"] workspaceServers [] killed true
ROW F (add, after recycle; subscriber attached to the session on the DRAINED generation):
  s1 still on gen1 (gen1 alive) = true
  add result = {"name":"internal-db",...,"toolCount":3}
  gen1 (owns s1) servers = []  calls = []
  gen2 (replacement) servers = ["internal-db"]  calls = ["qwen/control/workspace/mcp/runtime-add"]
  event seen by the session on the DRAINED generation = {"type":"mcp_server_added","data":{"name":"internal-db","toolCount":3}}

Correction to the causal framing, which does not change the verdict: the liveChannelInfo()admissibleChannelInfo() swap is not what retargets these routes during a drain — liveChannelInfo() also returns the replacement, because channelInfo is the replacement and is not dying. The retargeting comes from the recycle repointing channelInfo; the swap's own effect is to refuse (acp_channel_unavailable) when channelInfo itself is draining with no replacement spawned.

Suggested fix: route workspace-scoped MCP mutations to every work-owning generation rather than to the admission-eligible one — fan out over Array.from(aliveChannels).filter((c) => c.state !== 'dying'), aggregate the per-generation results, and report not_present only when no generation had the server (suppressing the broadcast in that case). If fan-out is rejected as too broad, refuse instead of silently skipping — throw the retryable BridgeRuntimeRecyclingError this diff already added (bridgeErrors.ts:676) when liveChannelInfo() !== admissibleChannelInfo(), so the caller retries after the drain instead of getting a false success.

Any fix must respect this: packages/cli/src/serve/acp-http/client-mcp-sender-registry.ts:265-270 — "the workspace-scoped add fans out to every active session AND is copied onto every session created later (acp-integration/acpAgent.ts)"; that fan-out is within one child process (Config.runtimeMcpServers, packages/core/src/config/config.ts:2287), so a fix must fan out across generations at the bridge layer, not rely on the child.

Please add the test that pins this: in packages/acp-bridge/src/bridge.test.ts, mark a generation draining while it owns a session, spawn the replacement, then issue a workspace-scoped MCP mutation and assert it was dispatched to the owner generation's connection (or that the call rejected with BridgeRuntimeRecyclingError) rather than resolving to a not_present skip on the replacement. Routing it through admissibleChannelInfo() must turn it red. The generation machine currently has zero test references, so no existing case covers this.

中文说明

[Critical] R1-36:依然存在 —— workspace 作用域的 runtime MCP 路由通过 admissibleChannelInfo() 解析目标,因此在排空期间它们作用在替代 generation 上,而所有存活会话仍留在正在 draining 的那一个上。removeRuntimeMcpServer 会以 HTTP 200 静默空转,addRuntimeMcpServer 则会广播一个没有任何现存会话能观察到的 workspace 级事件。

本条以原始 id 从第 1 轮的账本中延续下来,并在 HEAD 6bc80c0df4 上重新追踪、重新实测。提交 d7f26da1b5(“stop routing workspace mutations to draining generations”)把 generateWorkspaceAgent:14435)、addRuntimeMcpServer:14558)和 removeRuntimeMcpServer:14613)从 liveChannelInfo() 改为 admissibleChannelInfo()。但当一次回收把 generation A 标记为 draining 并发布了处于 active 的 B 之后,admissibleChannelInfo() 返回的是 B —— 那个不拥有任何会话的 generation —— 因此事故中的这一步没有改变。

会话 S1 中的一个后台 Agent 忽略中止;Session.ts:9788 请求回收 → requestRuntimeRecycleForSession 把 A 设为 draining,延迟其退役(retireWhenSessionsDrain = true:3765,因为 S1/S2 仍挂在其上),并且 ensureChannel('recovery') 派生出 B 并把 channelInfo = B:5220)。随后 DELETE /workspaces/:w/mcp/servers/internal-db 解析到 B;B 从来没有过 internal-db,于是子进程回答 {name, skipped: true, reason: 'not_present'}:14626 处的 RemoveSkip 形状),不广播任何 mcp_server_removed 事件,路由则以该 skip 响应体返回 HTTP 200workspace-mcp-control.ts:634-641)。internal-db 仍连接在 generation A 上 —— 而 A 仍然拥有每一个存活会话 —— 只要 A 还活着(可能无限久:A 只在其会话排空后才退役),它的工具就仍可从 S1/S2 调用。对称的 add 只把服务器写入 B,却向所有 workspace 订阅者广播 mcp_server_added,于是 Web Shell 会列出一个 A 上的会话无法调用的服务器。同一对象中会话作用域的孪生方法没有被改向 —— addSessionRuntimeMcpServer/removeSessionRuntimeMcpServerrequestSessionStatus(sessionId, …):14658-14678),会解析到条目所属的 generation —— 因此这两种作用域现在对“哪个子进程才是那个 runtime”给出了互相矛盾的答案。这违反了 issue #8586 第 4 层的验收条件“Runtime draining preserves owner-generation routing”。

证据(对真实 bridge 的探针)见上方英文代码块 ROW A / ROW F:workspace 作用域的 remove 落到了不拥有任何会话的 generation 上,并以成功形状的 200 skip 返回,而拥有存活会话的 generation 仍保留着 internal-db;workspace 作用域的 add 只落在替代 generation 上,却把 mcp_server_added 广播给了一个其会话 runtime 并不拥有该服务器的订阅者。两种作用域已被实测证明不一致(ROW A:workspace→gen2,session→gen1)。

对因果表述的一处更正,不改变结论:liveChannelInfo()admissibleChannelInfo() 的替换并不是排空期间让这些路由改向的原因 —— liveChannelInfo() 同样会返回替代者,因为 channelInfo 就是替代者且并未 dying。改向来自回收把 channelInfo 重新指向;这次替换本身的效果是:当 channelInfo 自身处于 draining 且尚未派生替代者时予以拒绝(acp_channel_unavailable)。

修复建议:把 workspace 作用域的 MCP 变更路由到每一个拥有工作的 generation,而不是路由到可准入的那一个 —— 对 Array.from(aliveChannels).filter((c) => c.state !== 'dying') 做扇出,聚合各 generation 的结果,并且只有在没有任何 generation 持有该服务器时才报告 not_present(此时同时抑制广播)。如果认为扇出过宽,那就拒绝而不是静默跳过 —— 当 liveChannelInfo() !== admissibleChannelInfo() 时抛出本 diff 已经加入的可重试 BridgeRuntimeRecyclingErrorbridgeErrors.ts:676),让调用方在排空后重试,而不是拿到一个虚假的成功。

修复必须遵守:packages/cli/src/serve/acp-http/client-mcp-sender-registry.ts:265-270 —— “workspace 作用域的 add 会扇出到每一个活跃会话,并且会被复制到之后创建的每一个会话(acp-integration/acpAgent.ts)”;该扇出发生在单个子进程内部Config.runtimeMcpServerspackages/core/src/config/config.ts:2287),因此修复必须在 bridge 层跨 generation 扇出,而不能依赖子进程。

请补上能钉住这一点的测试:在 packages/acp-bridge/src/bridge.test.ts 中,让某个 generation 在拥有会话的情况下被标记为 draining,派生出替代者,然后发起一次 workspace 作用域的 MCP 变更,并断言它被派发到了所有者 generation 的连接上(或者该调用以 BridgeRuntimeRecyclingError 拒绝),而不是在替代者上解析成 not_present 跳过。让它经由 admissibleChannelInfo() 时该测试必须变红。目前整个 generation 机制的测试引用数为零,因此没有任何既有用例覆盖此点。

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

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.

Leaving this unresolved. It is a routing decision, not a mechanical defect, and I could not find a fix that stays inside the guardrails this PR needs.

Verified at head, and the report's own correction to the causal framing is the load-bearing part:

  • liveChannelInfo() = channelInfo && !channelInfo.isDying (bridge.ts:6248-6249)
  • admissibleChannelInfo() = channelInfo?.state === 'active' (:6251-6252)

After a recycle repoints channelInfo at the replacement, both return the replacement. So the liveChannelInfo()admissibleChannelInfo() swap is not what retargets these routes during a drain, and reverting it would not fix the incident. I confirmed the swap's scope with git show d7f26da1b5: exactly three -/+ pairs, and all three workspace routes now read admissibleChannelInfo() at :14435, :14557, :14613.

Why I am not picking a fix. The repo already has an owner-generation resolver — channelInfoForEntry(entry) (:6254-6261) walks aliveChannels for the generation that owns a session, and that is what the session-scoped twins use (:14658-14678). But it is keyed by session. A workspace-scoped mutation has no single owning entry: during a drain there can be N sessions on the draining generation and zero on the replacement. Expressing "every work-owning generation" requires a new selection semantic, which is precisely the design question raised here.

Both proposed options change a contract I should not change unilaterally in a closeout pass:

  • Fan-out over aliveChannels.filter(c => c.state !== 'dying') with aggregated results makes a workspace mutation multi-target, and needs a new rule for partial success (gen1 removed, gen2 not_present) plus a decision on when the broadcast is legitimate.
  • Refusing with BridgeRuntimeRecyclingError (bridgeErrors.ts:676) turns an HTTP 200 skip into a retryable error on a public route (workspace-mcp-control.ts:634-641), and to stay coherent it would have to apply to generateWorkspaceAgent as well — a behaviour change for every caller of three endpoints.

The question for the maintainer, stated plainly: after a generation hand-off, which child is "the" runtime for a workspace-scoped MCP mutation, given that every live session sits on the draining generation and the replacement owns none? Whichever way that goes, the two scopes need to agree — today the session-scoped routes resolve the owner generation and the workspace-scoped ones resolve the admissible generation, which is the actual inconsistency behind the not_present skip and the unobservable mcp_server_added broadcast.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-36: [certifies-falsely] [regression] Still stands — the workspace-scoped runtime-MCP routes resolve their target through the newest admissible generation rather than the generation that owns the workspace's live sessions, so during a drain they mutate an empty replacement child while every live session stays on the draining one that never receives the change.

Failure scenario: A recycle marks generation A draining and publishes an empty replacement B. addRuntimeMcpServer resolves one channel via admissibleChannelInfo() and round-trips workspaceMcpRuntimeAdd only over that connection, then broadcasts mcp_server_added to every client. A is never told and cannot be: runtime-added servers live in a private per-Config map that loadCliConfig does not re-read, and the child-side handler fans out only to its own active sessions. A model-driven tools/call for that server from the live session then fails 'not found in registry' while the UI shows it as added. removeRuntimeMcpServer silently no-ops with HTTP 200.

Witness:

Read at HEAD 8b23578fc9 (Agent 0, issue-fidelity): commit d7f26da1b5 switched generateWorkspaceAgent (bridge.ts:14664), addRuntimeMcpServer (:14786) and removeRuntimeMcpServer (:14842) from liveChannelInfo() to admissibleChannelInfo(); addRuntimeMcpServer still resolves a single channel — the newest active one — and round-trips workspaceMcpRuntimeAdd only over info.connection, then broadcasts mcp_server_added to every client. queryWorkspaceStatus (:12601) and setUserLanguage (:13358) still take liveChannelInfo(). No workspace-mutation route fans out across aliveChannels or resolves to the session-owning generation.

Suggested fix: Give the retained slot a bounded exit. Either mirror cancel()'s deferred fallback inside failUnresponsive — an unref'd setTimeout that calls releaseRetainedPhysicalSlot(agentId) after a hard grace, so the flag can never outlive a bounded window — or (better, because it restores the user's remedy) let cancel() accept a failed-with-retainsPhysicalSlot entry and force-release: if (entry.retainsPhysicalSlot) { this.releaseRetainedPhysicalSlot(agentId); return; } ahead of the status !== 'running' early return, and drop the matching status !== 'running' refusal in `task-stop.ts

The fix must not violate this existing fact: background-tasks.ts:1489-1491 — "A watchdog-terminal run still counts while its underlying execution holds a physical slot, so session reset cannot erase the only remaining owner." A forced release re-opens the /clear gate, and reset() (background-tasks.ts:1512-1535) clears the map without aborting entry.abortController (unlike the workflow registry, which aborts

Acceptance criterion: packages/core/src/agents/background-tasks.test.ts — a new case that calls failUnresponsive(agentId, …), leaves the turn promise unsettled, then (fake timers) advances past the fallback grace and asserts entry.retainsPhysicalSlot is undefined and registry.hasRunningTasks() is false; equivalently, a task-stop test asserting a force-clear on a failed+retained entry Please confirm the mutation that proves it (remove the guard, run that test, confirm it goes red).

中文说明

工作区级的运行时 MCP 路由仍把目标解析为“最新的可接纳 generation”,而不是“拥有该工作区活跃会话的 generation”。回收把 A 标记为 draining 并发布空的替代 B 后,addRuntimeMcpServer 只通过 B 的连接往返 workspaceMcpRuntimeAdd,却向所有客户端广播 mcp_server_added;A 永远收不到,也无法收到(运行时添加的服务器存放在 loadCliConfig 不会重读的 per-Config 私有映射中,子进程侧只向自己的活跃会话扇出)。于是来自活跃会话的 tools/call 报 “not found in registry”,而 UI 显示已添加;removeRuntimeMcpServer 则静默 no-op 并返回 HTTP 200。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-36: [certifies-falsely] [regression] Still stands — the workspace-scoped runtime-MCP routes resolve their target through admissibleChannelInfo(), so during a drain they act on the replacement generation while every live session stays on the draining one that never receives the change

A recycle repoints channelInfo at the replacement generation while the draining one still owns the workspace's live sessions. addRuntimeMcpServer and removeRuntimeMcpServer both resolve through admissibleChannelInfo(), so the ext-method round trip goes to the empty replacement child: removeRuntimeMcpServer silently no-ops with HTTP 200 while the sessions that still need the server keep it, and addRuntimeMcpServer broadcasts a workspace-wide mcp_server_added event for a server the live sessions never got.

Witness:

not run — scratch-tree refused (available: false); settled by quoted lines at HEAD 90e2d6f7ae: `const info = admissibleChannelInfo();` at bridge.ts:14795 (add) and :14851 (remove), against liveChannelInfo() = channelInfo && !channelInfo.isDying (:6316-6317) and admissibleChannelInfo() = channelInfo?.state === 'active' (:6319-6320). The author replied "Leaving this unresolved. It is a routing decision, not a mechanical defect" and verified the same two predicates at head; a reply does not retire a blocker.

Suggested fix:
Resolve workspace-scoped runtime-MCP mutations against the generation that owns the workspace's live sessions (fan out to every non-dying generation, or route by session ownership) rather than the newest admissible one, or return the retryable 503 so the caller can wait for the drain to finish.

The fix must not violate this existing fact: bridgeTypes.ts:2531-2534 states a draining generation "still owns existing sessions but cannot accept new work", so a fix must not route fresh session admission back through liveChannelInfo(); the admissible predicate is correct for admission and wrong for mutating owned sessions.

Please confirm the mutation that proves it — packages/acp-bridge/src/bridge.test.ts — a case that puts a session on gen1, recycles, and asserts a runtime MCP remove reaches gen1 (the generation owning the session) rather than no-oping on gen2. Red if the route is switched back to admissibleChannelInfo().

中文说明

工作区级的 runtime-MCP 路由通过 admissibleChannelInfo() 解析目标,因此在排空期间它们操作的是替代代,而所有活跃会话仍留在 draining 代上,永远收不到这次变更。

后果:removeRuntimeMcpServer 会静默空操作并返回 HTTP 200,而仍需该服务器的会话照样持有它;addRuntimeMcpServer 会广播一个工作区级的 mcp_server_added 事件,而活跃会话从未拿到该服务器。

作者已回复“保持未解决——这是路由决策而非机械缺陷”,并在 head 上核实了同样的两个判定式。回复本身不能撤销阻塞项。

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

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.

Design-level — left open deliberately. Fixing this means giving the workspace-scoped runtime-MCP routes a draining-aware, generation-anchored resolution instead of admissibleChannelInfo(), which changes the routing contract for every one of those routes during a drain. That is a lifecycle/routing semantics change, not a guard fix, so it is not landable inside this round. Evidence anchor: packages/acp-bridge/src/bridge.ts:14854.

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.

Re-verified at the post-merge head 93eef769a7. Still stands: generateWorkspaceAgent (packages/acp-bridge/src/bridge.ts:14699), addRuntimeMcpServer (:14821) and removeRuntimeMcpServer (:14877) all resolve const info = admissibleChannelInfo(), against liveChannelInfo() = channelInfo && !channelInfo.isDying (:6316-6317) and admissibleChannelInfo() = channelInfo?.state === 'active' (:6319-6320). The session-scoped twins resolve by owning entry instead, so the two scopes disagree.

New evidence against the cheaper remediation proposed on this thread: refusing when liveChannelInfo() !== admissibleChannelInfo() does not fire in the reported scenario. After the recycle repoints channelInfo at the replacement, that replacement is alive and active, so both predicates return it — the guard only trips when channelInfo itself is draining with no replacement spawned, which is not the incident (the round-8 comment already noted this correction). Routing by session ownership is not expressible here either: a workspace-scoped mutation can face N sessions on the draining generation and zero on the replacement.

So the only fix that addresses the reported failure is generation-owning fan-out with new partial-success semantics — gen1 removed / gen2 not_present has to define a result, and the workspace-wide mcp_server_added broadcast has to become conditional. That is a routing-contract change, beyond this round. Leaving unresolved with the decision for the maintainer: fan out to every non-dying generation and define partial success, or reject workspace-scoped runtime-MCP mutations whenever a work-owning generation is not the active one.

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.

Round jmtz4tb6u1d independent re-verification at head 93eef769a7. REAL, and human-gated. Adding the structural proof for why the cheaper remediation cannot work -- the earlier reply asserted this, it is now derived:

Both predicates read the same single channelInfo binding:

  • liveChannelInfo() = channelInfo && !channelInfo.isDying ? channelInfo : undefined (bridge.ts:6318-6319)
  • admissibleChannelInfo() = channelInfo?.state === 'active' ? channelInfo : undefined (bridge.ts:6321-6322)

They can therefore only disagree when channelInfo itself is non-dying and not active -- i.e. draining with no replacement spawned yet. Once a recycle repoints channelInfo at the replacement, both return the reference-identical object, so a liveChannelInfo() !== admissibleChannelInfo() guard is unreachable in the reported scenario. Rejecting on that predicate cannot fix the incident.

The scope disagreement confirms at this head: the workspace routes resolve const info = admissibleChannelInfo() at :14699 (generateWorkspaceAgent), :14821 (addRuntimeMcpServer) and :14877 (removeRuntimeMcpServer), while the session-scoped twins resolve by owning entry through requestSessionStatus(sessionId, ...) at :14927 and :14936 -- the same channelInfoForEntry(entry) resolution the rest of the session surface uses (:3167, :3258, :3535, :3803).

A generation-owning fan-out is expressible here (aliveChannels is iterated at :2821 and membership-tested at :4035), so the blocker is not feasibility -- it is the contract. Fanning out forces a partial-success result on the workspace HTTP route (gen1 removed / gen2 not_present has to resolve to something, and workspace-mcp-control.ts currently maps that skip shape to HTTP 200), and makes the workspace-wide mcp_server_added broadcast conditional.

Gate: routing-contract plus public API change, applied to every workspace-scoped runtime-MCP route during a drain. Decision for the maintainer, unchanged: fan out to every non-dying generation and define partial success, or reject workspace-scoped runtime-MCP mutations whenever a work-owning generation is not the active one.

No code change this round. Leaving unresolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R1-36: [certifies-falsely] [regression] Still stands — and this round measured a second, worse consequence of the same root: under the default idle policy the misrouted mutation destroys the replacement generation and leaves the workspace with no admissible generation at all.

generateWorkspaceAgent, addRuntimeMcpServer and removeRuntimeMcpServer all resolve their target through admissibleChannelInfo(), which returns the newest active generation. During a drain that is the empty replacement, not the generation that owns the workspace's live sessions. So removeRuntimeMcpServer returns { skipped: true, reason: 'not_present' } — which workspace-mcp-control.ts answers with HTTP 200 — and a revocation silently no-ops while the draining owner's sessions keep the server. addRuntimeMcpServer succeeds against the replacement and then broadcasts mcp_server_added workspace-wide, although the owner never received it. The session-scoped twins do keep owner routing, so the diff already contains the correct pattern.

The new consequence: the mutation's withWorkspaceControl finally calls startIdleTimer(replacement, 'workspace control'), and channelIdleTimeoutMs defaults to 0 in production (run-qwen-serve.ts: "0 = immediate kill"), so that kills the replacement outright. Its exited handler clears channelInfo, after which admissibleChannelInfo() returns undefined and startIdleTimer bails on its own non-primary guard — and none of the three routes calls ensureChannel, so every later workspace mutation fails acp_channel_unavailable until unrelated fresh-session work happens to spawn a new generation. The draining owner stays alive serving every session the whole time.

Issue #8586's Layer-4 acceptance criterion is explicit — "Runtime draining preserves owner-generation routing" — and these three routes do not preserve it. The failure is silent (HTTP 200) for a mutation whose whole purpose is revocation.

Witness:

probe driving the real createAcpSessionBridge (production default, channelIdleTimeoutMs unset):
removeRuntimeMcpServer during drain returned: {"name":"srv","skipped":true,"reason":"not_present"}   -> HTTP route does res.status(200).json(result)
STATE after remove | gen1(owner of the live session): [] | gen2(replacement): ["runtime-remove"] | gen1.killed= false | gen2.killed= true
addRuntimeMcpServer during drain THREW: No live ACP channel for runtime MCP add: srv-new {"errorKind":"acp_channel_unavailable"}
control arm (channelIdleTimeoutMs: 300_000) | gen2.killed= false, gen2ext=["runtime-remove"]  <- wrong-generation mutation reproduces without the kill
control arm (no recycle)                    | gen1ext=["runtime-remove"]                     <- owner routing is correct when nothing is draining

Route every workspace-scoped mutation through the generation that owns the workspace's live sessions — channelInfoForEntry, or an explicit generation parameter — rather than the newest admissible one, and give partial failure defined semantics instead of a 200 that means nothing happened. Separately, bracket the recycle's ensureChannel with the reservation/settle machinery so a session-less replacement is not immediately reaped.

Any fix must keep the session-scoped twins' owner routing intact — bridge.ts:14921-14941 already route through requestSessionStatus(sessionId, …), and the design doc's contract for Sessions ("Existing Session entries continue to route through their recorded channel while it drains") must not regress.

Please add the test that pins this: in packages/acp-bridge/src/bridge.test.ts, mark a generation draining while it owns a session, spawn the replacement, issue a workspace-scoped mutation and assert it was dispatched to the OWNER generation's connection — routing it through admissibleChannelInfo() must turn it red.

中文说明

[Critical] R1-36:依然存在——并且本轮实测到同一根因的第二个、更严重的后果:在默认 idle 策略下,被错误路由的变更会销毁替代 generation,使整个 workspace 不再有任何可受理的 generation。

generateWorkspaceAgentaddRuntimeMcpServerremoveRuntimeMcpServer 都通过 admissibleChannelInfo() 解析目标,而它返回的是最新的 active generation。在 drain 期间那是空的替代 generation,而不是拥有该 workspace 活跃会话的那个。于是 removeRuntimeMcpServer 返回 { skipped: true, reason: 'not_present' }——workspace-mcp-control.ts 会以 HTTP 200 回应——撤销操作静默失效,而正在 drain 的 owner 上的会话仍保留着该 server。addRuntimeMcpServer 在替代 generation 上成功,随后向整个 workspace 广播 mcp_server_added,尽管 owner 从未收到。会话级的同类方法确实保留了 owner 路由,因此本 diff 内部已经有正确的写法。

新发现的后果:变更结束时 withWorkspaceControl 的 finally 会调用 startIdleTimer(replacement, 'workspace control'),而生产环境中 channelIdleTimeoutMs 默认为 0run-qwen-serve.ts:"0 = immediate kill"),因此这会直接杀掉替代 generation。其 exited 处理器清空 channelInfo,此后 admissibleChannelInfo() 返回 undefined,startIdleTimer 又因自身的非 primary 判断而直接退出——而这三条路由都不调用 ensureChannel,于是之后每一次 workspace 变更都会以 acp_channel_unavailable 失败,直到某个无关的新会话工作恰好派生出新的 generation。整个过程中正在 drain 的 owner 一直存活并服务着所有会话。

issue #8586 的 Layer-4 验收标准写得很明确——"Runtime draining preserves owner-generation routing"——而这三条路由并未做到。对于一个以撤销为目的的变更,其失败还是静默的(HTTP 200)。

修复方向:让每一次 workspace 级变更都路由到拥有该 workspace 活跃会话的 generation——channelInfoForEntry,或显式的 generation 参数——而不是最新的可受理 generation;并为部分失败定义明确语义,而不是返回一个什么也没发生的 200。另外,请用 reservation/settle 机制包裹回收路径的 ensureChannel,使没有会话的替代 generation 不会被立即回收。

修复约束:必须保持会话级同类方法的 owner 路由不变——bridge.ts:14921-14941 已经通过 requestSessionStatus(sessionId, …) 路由,且设计文档对 Session 的约定("Existing Session entries continue to route through their recorded channel while it drains")不得回退。

请补充测试:在 packages/acp-bridge/src/bridge.test.ts 中,让某个 generation 在持有会话时被标记为 draining,派生替代 generation,发起一次 workspace 级变更,并断言它被派发到 owner generation 的连接上;若仍经 admissibleChannelInfo() 路由,该测试必须为红。

(证据见上方 Witness 代码块;witness 为程序输出,未翻译。)

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

yiliang114 and others added 2 commits September 10, 2026 09:38
doSpawn's pre-`newSession` guard was tightened to the three-state model
(`ci.state !== 'active'`), but the paired post-`newSession` re-check was
left at the old two-state test `if (ci.isDying)`. `isDying` is a getter
for `state === 'dying'`, so a `draining` channel sailed through it.

A recycle landing inside the `newSession` round-trip takes exactly that
path: `requestRuntimeRecycleForSession` sets `owner.state = 'draining'`,
and because `hasNoSessionWork` counts the in-flight spawn
(`inFlightSpawnCount = ci.sessionSpawnsInFlight`) it takes the defer
branch — `retireWhenSessionsDrain = true`, no kill, `isDying` stays
false. The pending `newSession` then resolved past a re-check that only
tested `isDying`, registering a brand-new session on the condemned
generation (and claiming `defaultEntry` under `single` scope). The fresh
session shared the runtime the daemon had just judged unsafe for fresh
work, and the generation could not drain until that session closed.

Mirror the twin guard. The throw stays inside the existing `try` so the
`finally` still decrements `sessionSpawnsInFlight`; leaking that counter
would leave the drained generation unreapable, since it is what makes
`reapPendingEmptyChannel` refuse to reap.

For a draining channel the late child-side session is closed by the
existing bounded `sessionClose` round-trip rather than the
`await ci.channel.exited` short-circuit, and `reapPendingEmptyChannel`
still declines to kill while a live session holds the generation.

Test drives the real bridge: two sessions multiplexed on gen1, the
second held inside `connection.newSession`, recycle fired for the first
mid-flight, then released. Before the fix the spawn resolved with
`sess-drain-b` installed on the draining generation; after it, the spawn
rejects with `BridgeChannelClosedError` and gen1 is left alive for its
surviving session.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtusclhbul
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

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

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — head b19532e

Reviewed by reading the diff and the affected files at this SHA. No local test run.

Verdict: ESCALATE to maintainer. 28 files across packages/core/src/agents/**, packages/acp-bridge, packages/cli/src/serve/** and the TUI — cross-package, and it introduces a new abort authority over running agents. One Critical below. Bilingual design docs are present and paired, which I appreciated.

Critical — a clock-drift re-arm silently discards the retry deadline extension

packages/core/src/agents/runtime/agent-progress-watchdog.ts. schedule re-arms rather than firing when the timer came back early or late (:79-93):

const timer = setTimeout(() => {
  if (performance.now() - expectedAt > 1_000) {
    rearm();
    return;
  }
  callback();
}, timeoutMs);

and armModel passes itself as that rearm callback (:99-119):

const armModel = (retryDelayMs = 0) => {
  ...
  modelTimer = schedule(
    MODEL_CONTROL_PROGRESS_TIMEOUT_MS +
      Math.min(retryDelayMs, MAX_RETRY_DEADLINE_EXTENSION_MS),
    () => abort(...),
    armModel,
  );

rearm is the bare function reference, so the re-arm call receives no argument and retryDelayMs falls back to its = 0 default. Any extension granted for a long provider backoff — up to MAX_RETRY_DEADLINE_EXTENSION_MS, i.e. six hours — is discarded the first time the drift branch fires, and the deadline collapses to the base MODEL_CONTROL_PROGRESS_TIMEOUT_MS of 15 minutes. The drift branch is precisely what triggers on laptop sleep/resume and on a loaded host, which is also when a long retry is most likely to be in flight, so an agent that is legitimately waiting gets aborted with AgentProgressTimeoutError('model/control', ...). Capturing the current retryDelayMs in the closure passed as rearm (() => armModel(retryDelayMs)) fixes it; a test that arms with a large retryDelayMs, forces the drift branch, and asserts the deadline is still extended would keep it fixed.

Retracting a concern I checked and could not sustain

I want to be explicit about this rather than leave it implied. ensureChannel(admission: 'fresh' | 'recovery' = 'fresh') at packages/acp-bridge/src/bridge.ts:4603-4604 reads admission only inside a stderr diagnostic at :4622:

`qwen serve: runtime recycling blocked ${admission} work; generations=${...}`

That looks like an unpopulated parameter at a glance, but it is not: 'recovery' is passed at bridge.ts:3788, :8502 and :11366, with the default covering :4090, :14514 and :15106. A parameter that exists purely to make a log line say which kind of work was blocked is a legitimate diagnostic, and the distinction it records is genuinely useful when triaging a recycling stall. No change wanted here.

Suggestion — the model watchdog is fully disarmed while any tool is executing

:101-107:

if (
  disposed ||
  waitingForExternalInput ||
  [...tools.values()].some(
    (tool) => tool.state === 'executing' || tool.parkedOnInput === true,
  )
)
  return;

That is the right call for the model deadline, and TOOL_PROGRESS_TIMEOUT_MS covers the tool itself, so there is no gap for a tool that is merely slow. The case I could not convince myself is covered is a tool that completes its own deadline bookkeeping but never transitions out of executing in this map — then neither timer is armed. Worth a test that drops a tool entry into a terminal-but-unreported state and asserts something still fires, if only to prove the state machine cannot reach it.

The clock-drift branch of `schedule` called `rearm()` with no argument, and
`armModel` was passed as that bare reference, so the first drift re-arm dropped
the granted provider-backoff extension and collapsed the model deadline back to
the 15-minute base timeout. Re-arm through a closure capturing `retryDelayMs`.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtvk7oj0vo
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Thanks @doudouOUC — review 5167462288 confirmed at b19532e and fixed in 89cb7d6.

Clock-drift re-arm. schedule's drift branch called rearm() with no argument (agent-progress-watchdog.ts:86-89) while armModel was handed over as the bare reference, so the re-arm took retryDelayMs = 0 and discarded any granted extension. Re-armed through () => armModel(retryDelayMs) (:119-122), the closure you suggested.

Regression test keeps a provider-backoff deadline extension across a clock-drift re-arm in packages/core/src/agents/runtime/agent-progress-watchdog.test.ts: arms with a 60-minute retryDelayMs, pushes a stubbed performance.now() 2s ahead of the fake clock to land the extended timer in the drift branch, then asserts the deadline is still extended. On unpatched code it fails with expected 'model/control' to be undefined — the re-arm collapses to the 15-minute base deadline.

Suggestion — model watchdog disarmed while a tool executes: declined a test; the state is unreachable through the public API.

  • tools.set(..., { state: 'queued' }) (:166) is the only insertion point, and state = 'executing' (:198) is immediately followed by armTool(event.callId) (:200). armTool (:127) declines only when state !== 'executing', so executing always implies an armed tool timer.
  • Every transition out of executing clears the timer and leaves the state: settled/result (:172-176, :212-216), approval (:181-187, :206-212), external-input park (:188-196). No path clears the timer while remaining executing, so there is no "silent and unarmed" combination — a terminal-but-unreported tool is the ordinary stalled-tool case, and the 10-minute tool deadline still fires (covered).
  • The one genuinely unarmed state is the unbounded parkedOnInput external-input wait (:191-195, clearModel() with no re-arm). That is deliberate — it mirrors a top-level Monitor-owned wait — and is already pinned by keeps a nested external-input wait free of any deadline until progress resumes.
  • The post-abort limbo (tool timer spent, entry still executing) has already aborted the run, so it is not a silent state.

The 5 bot Criticals on this PR stay open as escalated maintainer decisions on watchdog/abort semantics, and are untouched here.

Targeted verification: npx vitest run src/agents/runtime/agent-progress-watchdog.test.ts → 7/7 passed; npm run typecheck in packages/core → clean.

…t-watchdog

Refresh the branch onto main so the Lint & Static lane passes the
`Check lint gate freshness` gate (main changed .github/workflows/ci.yml
in 17990c3).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtvkxeirvp
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

  • bridge.ts:5701 orphaned child-side session after the widened post-newSession rejection — already reported (R4-3 / R4-4, and answered by the author in the R6-4 thread)
  • agent-progress-watchdog.ts:204 approval-park deadline — already reported (R6-1); mechanism re-verified true this round, not re-filed
  • background-agent-resume.ts:1449 retained physical slot unbounded off-daemon — already reported (R1-21)

Unresolved, please confirm:

  • [Critical] R6-5 packages/core/src/core/llm-chat.ts:457 — could not determine whether the new retry-delay reporting now reaches the context-compression side query: at HEAD that line sits inside getHardRescueFailureMessage (a message builder), and the c…

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally (Agent 7 ran per-workspace vitest only).

Not reviewed: test-efficacy — all three probes returned inconclusive (runner produced no parseable JSON), so no coverage-gap claim was measured either way and the positive control never ran (harnessValidated: null).

Not reviewed: reverse audit — stopped at the 5-round cap without two consecutive dry rounds; rounds 3, 4 and 5 all reported findings (chunks 5 and 6 retired on two-dry certificates).

Not explored to full depth (tool budget reached): "agent reverse-audit (round 4)": whether updateOutput is actually wired for a depth-2 nested background agent — i.e. whether the task_execution chunk that carries the new park flags reaches…; "agent reverse-audit (round 4)": none — I also examined, and deliberately did not file, the unguarded sessionRuntimeRecycle ext-method call in the finally when this.closing / this.disposed…; "agent reverse-audit (round 4)": did not finish tracing whether a workflow-dispatched agent can reach runBackgroundTurn (packages/core/src/tools/agent/agent.ts:3861-3866), i.e. whether the ne….

Deferred under the convergence posture (round 7, not a blocker) — recorded, not requested in this round; 3 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:

  • packages/acp-bridge/src/bridge.ts:3811 — [review] Critical [fails-closed] [new-surface] Recycle recovery spawn never reaches the idle/reap policy
  • packages/acp-bridge/src/bridge.ts:5699 — [probe] Critical [fails-closed] [new-surface] R6-4: (fix-induced) widened post-newSession rejection fails
  • packages/core/src/agents/runtime/agent-core.ts:2019 — [review] Critical [fails-closed] [new-surface] A sibling’s execution voids the model deadline bounding an a
  • docs/design/background-agent-runtime-generations.md:1 — [review] This PR — which #8586's thread records as *"Layer 3 is now
  • docs/design/background-agent-runtime-generations.md:13 — [review] The design doc this PR ships states the opposite of what t
  • packages/acp-bridge/src/bridge.test.ts:29181 — [probe] This added rationale states the opposite of the shipped ad
  • packages/acp-bridge/src/bridge.ts:4639 — [review] ensureChannel still runs cancelIdleTimer() before this
  • packages/acp-bridge/src/bridge.ts:4642 — [review] A recycle refused by the two-generation cap is never re-at
  • packages/acp-bridge/src/bridge.ts:9763 — [review] requestRuntimeRecycle is added to the exported AcpSessi
  • packages/acp-bridge/src/bridge.ts:14664 — [review] Switching the three workspace-scoped routes to admissible
  • packages/acp-bridge/src/bridgeTypes.ts:2531 — [review] The replaced doc for isChannelLive() now promises admiss
  • packages/cli/src/acp-integration/session/Session.ts:9876 — [review] The record-only terminal notification is routed into the s
  • packages/cli/src/acp-integration/session/Session.ts:10091 — [review] Nothing in the suite covers the record-only notification p
  • packages/cli/src/nonInteractiveCli.ts:2802 — [review] Nothing in the suite pins the headless half of the record-
  • packages/cli/src/serve/acp-http/dispatch.ts:918 — [review] The new retryable 503 carries no retryAfterSeconds and t
  • packages/cli/src/serve/server/error-response.ts:383 — [probe] The new fence is routed into the daemon's bridge-error met
  • packages/cli/src/ui/hooks/use-llm-stream.ts:6314 — [review] The interactive TUI's background-agent notification callba
  • packages/core/src/agents/background-agent-resume.ts:1315 — [review] The resume path's new watchdog integration — the retainsP
  • packages/core/src/agents/background-tasks.ts:1497 — [review] Making hasRunningTasks() count a watchdog-terminal entry
  • packages/core/src/agents/background-tasks.ts:1733 — [review] The <remaining> / <all-terminal> count inside emitNot
  • …and 15 more (see the run report)

Convergence: round 7 posted 6 inline comment(s), 1 of them reported for the first time; the previous round posted 6 (5 new). Findings keep coming back to the same files: packages/acp-bridge/src/bridge.ts (findings in rounds 1, 6; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

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

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

未决,请确认:共 1 条(原文未翻译,列表见上方英文部分)。

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

未审查(原文为英文):test-efficacy — all three probes returned inconclusive (runner produced no parseable JSON), so no coverage-gap claim was measured either way and the positive control never ran (harnessValidated: null).

未审查(原文为英文):reverse audit — stopped at the 5-round cap without two consecutive dry rounds; rounds 3, 4 and 5 all reported findings (chunks 5 and 6 retired on two-dry certificates).

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 4)"whether updateOutput is actually wired for a depth-2 nested background agent — i.e. whether the task_execution chunk that carries the new park flags reaches…"agent reverse-audit (round 4)"none — I also examined, and deliberately did not file, the unguarded sessionRuntimeRecycle ext-method call in the finally when this.closing / this.disposed…"agent reverse-audit (round 4)"did not finish tracing whether a workflow-dispatched agent can reach runBackgroundTurn (packages/core/src/tools/agent/agent.ts:3861-3866), i.e. whether the ne…

收敛姿态下延后(第 7 轮,非阻断)——已记录,本轮不要求修改;其中 3 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 35 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 7 轮发布了 6 条行内评论,其中 1 条是首次提出;上一轮发布了 6 条(其中 5 条首次提出)。发现反复回到同一批文件:packages/acp-bridge/src/bridge.ts(第 1、6 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

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

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

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agent-assisted review at 8b23578fc905b0516c2818bf3916d79059632d30 — Partial review — coverage gaps. Four confirmed correctness findings (three historical, one newly identified below). Comment only; no approval implied.

Critical findings

  1. The generation-cap rollback re-admits the runtime just judged unresponsive. packages/acp-bridge/src/bridge.ts:3796-3824 marks the session owner draining, but on BridgeRuntimeRecyclingError restores owner.state = 'active' and clears retirement. Recycle A with attached sessions to create B; then an abort-ignoring agent on B requests recycle. Both generations count toward the cap (:4642-4654), so the catch reactivates B. The next fresh session is sent straight back to B (:4639-4640, :6312-6313), sharing the unresponsive execution and retained slots. The new bridge test explicitly asserts this fallback, rather than detecting it. It contradicts the design's requirement not to route fresh work back to the condemned generation. Preserve fail-closed admission when replacement capacity is exhausted, or introduce an explicit recovery decision based on actual health; do not treat failure to allocate a replacement as evidence that its owner is safe. This is distinct from the earlier post-newSession guard fix.
  2. R1-36 still stands: workspace MCP mutations miss live owner generations. bridge.ts:14780-14883 targets only admissibleChannelInfo(). After A drains and B replaces it, removal executes on B and can return not_present/HTTP 200 while A's sessions retain the server; addition broadcasts success to workspace subscribers although A never received it. Verified real consumers: packages/cli/src/serve/routes/workspace-mcp-control.ts:552-645 resolves the selected trusted workspace and calls this bridge; acp-integration/acpAgent.ts:12649-12720 fans out only within the receiving child. Session-scoped twins (bridge.ts:14886-14904) retain owner routing. Choosing a newer generation does not implement workspace-wide mutation. Coordinate all work-owning generations with defined partial-failure semantics, or reject the mutation while that contract cannot be met.
  3. R6-3 still stands: nested retry delays disappear before the parent watchdog. packages/core/src/tools/agent/agent.ts:1486-1495 forwards nested MODEL_RETRY as an empty, throttled display update; the actual foreground listener is installed at :3137-3144. agents/runtime/agent-core.ts:2009-2026 converts it to TOOL_PROGRESS without a delay, so agent-progress-watchdog.ts:125-140,198-201 grants only ten minutes. A nested call honoring a one-hour Retry-After is killed while the same direct call receives the explicit extension (agent-core.ts:1025-1041; utils/retry.ts:496-501,528-539). Preserve the expected backoff through the nesting boundary with the existing bounded-extension policy, rather than treating it as generic activity.
  4. R6-5 still stands: cancel-first bypasses physical-slot protection and recycle. agent-progress-watchdog.ts:67-78 never arms escalation if another caller already aborted the signal. A user cancellation does exactly that (background-tasks.ts:967-1013); its five-second fallback emits the notification and drains queued launches (:1068-1080). An abort-ignoring run then stops counting at :1326-1333, with no retained slot and no later watchdog recycle. failUnresponsive also refuses an already-notified entry (:920-928). The added registry test calls escalation directly before cancellation finalizes, so it does not cover this real ordering. Keep physical execution ownership until settlement on the cancel-first path as well; a synthetic terminal notification must not free a still-executing slot.

Previous-review reassessment

  • Our prior clock-drift Critical is fixed: the retry extension is captured by () => armModel(retryDelayMs) at watchdog:122; the new test exercises the re-arm.
  • R6-4's post-newSession admission defect is fixed at bridge:5701-5702. This does not certify all abandoned-session cleanup (see gaps).
  • Fixed in the inspected mechanisms: R1-2 optional notification metadata; R1-3 ordinary nested activity forwarding (not retry semantics); R1-4 indefinite retry suspension/overflow now has a bounded timer; R1-23 per-tool settlement is emitted before whole-batch completion; R1-25 original retained-slot gate and its blocking-label mirror; R1-24/R1-33 direct notification emission now uses queues; R5-1 display rejection is caught and end-turn helper catches transport failure (Session.ts:10281-10293,10768-10781); R4-6 soft timeout no longer itself marks a generation draining (bridge.ts:3781-3793,3829-3836). R3-1's overbroad ROUND_END suspension now requires the explicit wait marker, not merely a running Monitor.
  • R6-1 approval timeout and R6-2 whole-unwind five-second grace remain as implemented, not fixed. The documented bounded approval behavior conflicts with older review guidance/test-plan text; terminal-record ownership on late cleanup also remains a maintainer decision. R1-29's cancel-versus-watchdog terminal semantics likewise must not be called resolved merely because a later test chooses the opposite policy. Existing escalation/deferral discussions remain open; I am not inventing another timeout policy here.

Coverage and limits

Read the complete current 28-file diff, new tests and paired design docs; inspected watchdog state machine, registry/cancel/slot consumers, event producers, notification queues, recycle handler and bridge admission/MCP owner routing. The private recycle route is live-session-owner scoped (BridgeClient validates its channel's sessionIds, existing entry and reason); its channel factory retains the bound workspace/environment. MCP add/remove and agent generation are selected-runtime/workspace operations, not process-global or primary-runtime fallback operations. HTTP/RPC recycling error mappings remain 503/retryable. Approximately 835 non-comment source lines changed (970 non-test source lines including comments); verified admin/maintainer author, so no external-refactor policy block.

Coverage gaps: this is not exhaustive validation of the large bridge's restore/branch/abandoned-child cleanup, all workspace-control/liveness consumers, hook unwinds, multi-level parked-state propagation, or compression/provider-internal retries. Historical R1-1/R1-5/R1-17, R3-3, R4-2/3 and terminal-hook R4 variants, R5-2/3, and R6-6 are cannot tell comprehensively in this pass, not cleared; repeated IDs refer to their described mechanisms/anchors. Other old resolved flags were not treated as proof. No repository tests/build, daemon/provider execution, or timing experiments ran for this PR. Existing Suggestions remain deferred; no new Suggestions. A posted partial review is not full review completion.

yiliang114 and others added 2 commits September 11, 2026 21:46
An approval-pending background agent was still charged the 15-minute
model deadline, so a user who parked an approval for longer than that
got a false watchdog failure. Issue #8586's acceptance criteria forbid
approval waits from causing watchdog failures, so exempt the approval
state from the model deadline just like external-input waits.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtwznj86xz
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

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

Only clear retireWhenSessionsDrain when this recycle itself set it; a
rollback must not erase a reap-after-drain condemnation a different
condemnor (timeout, MCP discovery/auth) had already flagged on the channel.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtxl34z1yy

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

  • recordOnly terminal notification dropped in the interactive TUI (packages/cli/src/ui/hooks/use-llm-stream.ts:6314) — already reported as R1-9 (comment 3950282815)
  • isChannelLive() doc describes admission while the implementation reports liveness (packages/acp-bridge/src/bridgeTypes.ts:2532) — already reported (round-3 deferral record)
  • the recycle's replacement generation is never handed to the channel idle-reap policy (packages/acp-bridge/src/bridge.ts:3816) — already reported as R3-10 (round-3 review 5142185428)
  • the public AcpSessionBridge.requestRuntimeRecycle member has no caller and the shipped child-to-daemon route is untested (packages/acp-bridge/src/bridgeTypes.ts:2549) — already reported as R1-11 (comment 3950282825)
  • the daemon's record-only path has no test (packages/cli/src/acp-integration/session/Session.ts:10298) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • neither new runtime_recycling 503 mapping is tested (packages/cli/src/serve/acp-http/dispatch.ts:918) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • BridgeRuntimeRecyclingError missing from the daemon metrics known-error allowlist (packages/cli/src/serve/server/error-response.ts:383) — already reported as R1-10 (comment 3950282821), author confirmed still standing at head
  • the escalation callback onUnresponsive is asserted nowhere (packages/core/src/agents/runtime/agent-progress-watchdog.test.ts:65) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • the new settled flag is never emitted by any test (packages/core/src/agents/runtime/agent-progress-watchdog.test.ts:48) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • the agent-headless progress-timeout mapping has no test (packages/core/src/agents/runtime/agent-headless.ts:402) — already reported in the missing-test aggregate over the abort-reason TIMEOUT mappings (round-3 review 5142185428)
  • agent-core's producer-side watchdog emissions are untested (packages/core/src/agents/runtime/agent-core.ts:1399) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • releaseRetainedPhysicalSlot and the retained-slot accounting have no test (packages/core/src/agents/background-tasks.ts:943) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • the headless recordOnly consumer is untested (packages/cli/src/nonInteractiveCli.ts:2800) — already reported in the missing-test aggregate over recordOnly routing (round-3 review 5142185428)
  • the admissibleChannelInfo() swap and per-generation identity fixes are untested for two coexisting generations (packages/acp-bridge/src/bridge.ts:14673) — already reported in the missing-test aggregate over the two-generation admission cap …
  • describeBlockingBackgroundWork's widened predicate and 'still stopping' label have no test (packages/cli/src/ui/utils/backgroundWorkUtils.ts:108) — already reported in the missing-test aggregate over retainsPhysicalSlot accounting (round-3 …
  • no case runs an executing tool past MODEL_CONTROL_PROGRESS_TIMEOUT_MS (packages/core/src/agents/runtime/agent-progress-watchdog.test.ts:74) — already reported in the missing-test aggregate over the watchdog module (round-3 review 5142185428…
  • the retainsPhysicalSlot early exits in agent.ts and background-agent-resume.ts have no test (packages/core/src/tools/agent/agent.ts:3725) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • the suite never emits START/STREAM_TEXT/USAGE_METADATA so onActivity is unexercised (packages/core/src/agents/runtime/agent-progress-watchdog.test.ts:30) — already reported in the missing-test aggregate over the watchdog module (round-3 rev…
  • armTool's drift re-arm closure is unpinned (packages/core/src/agents/runtime/agent-progress-watchdog.ts:141) — already reported in the missing-test aggregate over the watchdog module (round-3 review 5142185428)
  • the new retryable 503 carries no Retry-After / retryAfterSeconds backoff hint (packages/cli/src/serve/server/error-response.ts:354) — already reported (round-3 deferral record)
  • …and 2 more (see the run report)

Not reviewed: reverse audit — reached the 5-round cap for a large diff without two consecutive dry rounds; round 5 still reported findings, so what it surfaced is verified but the loop never converged.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were also skipped and the same unit suites ran on Linux, so only the Linux unit ground is covered.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": whether registerOwnedMonitorNotifications 's wake fires when a Monitor task reaches a terminal status without delivering a message — the orphaned-waiter prem…; "agent reverse-audit (round 3)": whether the modified fake's exited resolving with undefined (instead of a real {exitCode, signalCode} ) drives the channel.exited handler down a differen….

Deferred under the convergence posture (round 8, not a blocker) — recorded, not requested in this round; 2 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:

  • packages/core/src/agents/runtime/agent-progress-watchdog.ts:154 — [review] Critical [fails-closed] [new-surface] The external-input latch is cleared only by ROUND_START …
  • packages/core/src/agents/runtime/agent-core.ts:2104 — [review] Critical [fails-closed] [new-surface] The new TOOL_PROGRESS emit is placed *above* the…
  • packages/acp-bridge/src/bridge.ts:3805 — [review] A draining generation is an OS-live ACP child that still…
  • packages/acp-bridge/src/bridge.ts:3805 — [review] A recycle that lands while a spawn is inside…
  • packages/acp-bridge/src/bridge.ts:3827 — [review] The rollback's clearing of retireWhenSessionsDrain is…
  • packages/acp-bridge/src/bridge.ts:1098 — [review] Inserting state between the load-bearing JSDoc block and…
  • packages/core/src/agents/runtime/agent-progress-watchdog.ts:106 — [review] ToolDeadline.parkedOnInput is a dead switch — its only…
  • docs/design/background-agent-runtime-generations.md:13 — [review] Both language versions state the only recovery from the…
  • packages/acp-bridge/src/bridge.ts:14673 — [review] generateWorkspaceAgent now reports a *draining* runtime…
  • packages/core/src/agents/runtime/agent-headless.ts:402 — [review] The new catch arm classifies *any* exception as a progress…
  • packages/core/src/agents/runtime/agent-progress-watchdog.ts:112 — [review] The model deadline armModel actually enforces is…
  • packages/acp-bridge/src/bridge.test.ts:32729 — [review] The new test pins the drain-race refusal to…
  • packages/cli/src/ui/utils/backgroundWorkUtils.ts:108 — [review] The — still stopping qualifier is appended to the label…
  • packages/core/src/agents/runtime/agent-progress-watchdog.test.ts:181 — [review] The clock-drift branch in agent-progress-watchdog.ts:86 …
  • packages/acp-bridge/src/bridge.ts:4661 — [review] The two-generation cap counts the still-draining…
  • packages/core/src/agents/background-tasks.ts:930 — [review] The watchdog-escalation terminal transition persists a…
  • packages/core/src/agents/runtime/agent-core.ts:2188 — [review] The settled transition is emitted unconditionally for…
  • packages/core/src/tools/agent/agent.ts:1589 — [review] waitingForApproval() is derived from currentToolCalls …
  • packages/acp-bridge/src/bridge.test.ts:29181 — [review] This new comment states an admission invariant the code…
  • packages/core/src/agents/background-tasks.ts:1497 — [review] The widened hasRunningTasks() predicate has a second…

Convergence: round 8 posted 6 inline comment(s), 2 of them reported for the first time; the previous round posted 6 (1 new). Findings keep coming back to the same files: packages/acp-bridge/src/bridge.ts (findings in rounds 1, 7; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (6 Critical(s)), the rate of first-time findings is not falling (this round 2, previous 1), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):

standing Critical attack surface attacker-dependency blast radius
(each standing Critical)

Advisory only — it does not block this review.

中文说明

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

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

未审查(原文为英文):reverse audit — reached the 5-round cap for a large diff without two consecutive dry rounds; round 5 still reported findings, so what it surfaced is verified but the loop never converged.

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were also skipped and the same unit suites ran on Linux, so only the Linux unit ground is covered.

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"whether registerOwnedMonitorNotifications 's wake fires when a Monitor task reaches a terminal status without delivering a message — the orphaned-waiter prem…"agent reverse-audit (round 3)"whether the modified fake's exited resolving with undefined (instead of a real {exitCode, signalCode} ) drives the channel.exited handler down a differen…

收敛姿态下延后(第 8 轮,非阻断)——已记录,本轮不要求修改;其中 2 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 20 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 8 轮发布了 6 条行内评论,其中 2 条是首次提出;上一轮发布了 6 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/acp-bridge/src/bridge.ts(第 1、7 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 6 条 Critical),首次发现的速率没有下降(本轮 2,上一轮 1),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。

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

Comment thread packages/core/src/agents/background-tasks.ts Outdated
Comment thread packages/acp-bridge/src/bridge.ts
yiliang114 and others added 2 commits September 13, 2026 01:36
…tores on drained generations

Two round-8 review findings:

- acp-bridge: `loadSession`/`unstable_resumeSession` only re-tested `ci.isDying`
  after the restore round-trip, so a runtime recycle landing mid-restore
  installed a brand-new session on the condemned generation. The recycle leaves
  the channel `draining` with `isDying === false`, because retirement is
  deferred while that very restore is in flight (`hasNoSessionWork` counts
  `pendingRestoreCount`), so the guard passed and `createSessionEntry` pinned
  the drained generation open until the new session closed. Mirror the
  post-`newSession` twin's three-state test: `ci.state !== 'active'`.

- core: `failUnresponsive` bailed out on `entry.notified`, so when the
  cancel-grace timer (`CANCEL_GRACE_MS`) finalized the cancellation — emitting
  the terminal notification and setting `notified` — before the drift-guarded
  watchdog escalation landed, the escalation was dropped entirely and
  `retainsPhysicalSlot` was never set, even though `onUnresponsive` can only
  fire while the execution is provably still alive. Reaching the escalation is
  itself proof that the execution still holds a physical slot, so settle the
  entry and retain the slot regardless of the already-delivered notification.
  `emitNotification` stays idempotent, so the terminal notification is not
  re-fired.

Regression tests sit beside the existing twins and both fail when their guard
is reverted: packages/core/src/agents/background-tasks.test.ts and
packages/acp-bridge/src/bridge.test.ts.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtyno0zh0n
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

Patrol-Run: qwen-pr-closeout/jmtypt6si0q
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Integrity audit of the resolved [Critical] review threads on this PR (head 93eef76), run because 36 threads were resolved with no author reply on the record.

Verified this round, highest-severity first — all four judge the cited hazard obsolete at this head, so all stay resolved:

  1. R1-23 (agent-progress-watchdog.ts:217) — per-call tool deadline is now retired on that call's own terminal status, not at batch completion. Fixed in 224c7a4. Reply: fix(core): time out stalled background agents #11270 (comment)
  2. R1-3 (agent-progress-watchdog.ts:174) — nested/foreground subagent progress now renews the parent's tool deadline via forwardProgress. Fixed in 224c7a4. Reply: fix(core): time out stalled background agents #11270 (comment)
  3. R1-4 (agent-progress-watchdog.ts) — retry extension is now capped (6h), not bounded only by the timer ceiling. Fixed in f8fbce0. Reply: fix(core): time out stalled background agents #11270 (comment)
  4. R3-1 (agent-progress-watchdog.ts) — ROUND_END suspends the deadline only on an explicit pre-wait flag. Fixed in f8fbce0. Reply: fix(core): time out stalled background agents #11270 (comment)

Nothing was re-opened. Two notes for the reviewer: the per-call settled path and the nested-progress bridge have no collocated test, and whether a 6h retry extension is right against a 15-minute liveness deadline remains the human-gated design call (it overlaps the maintainer's R3-2 condition).

Remaining 32 silent-resolved Criticals: "resolve justified, not audited this round". No code was changed.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Closeout round jmtz4tb6u1d — independent re-verification at head 93eef769a7 (no code change)

Verdict: needs a maintainer decision. All four unresolved Criticals were re-derived from source at this head rather than inherited from the previous round's notes. All four are REAL; none has a mechanical fix inside this round's scope ceiling (≤3 files, no new cross-package transport, no product-behaviour / public-API / semantics change). Per-thread replies carry the anchors.

Finding Anchor verified at 93eef769a7 Gate hit
R6-2 escalation grace vs. cooperative unwind agent-progress-watchdog.ts:19, abort() :67-78, only clear at :239 inside the detach closure that runs at agent.ts:3915 — same .finally as both releaseRetainedPhysicalSlot call sites (agent.ts:3916, background-agent-resume.ts:1437) concurrency / settle semantics
R6-3 retry extension cannot cross nesting agent.ts:1477-1485 (forwardProgress is zero-arity, so the delay is dropped by signature before the 1/s throttle), agent-events.ts:181-194, agent-progress-watchdog.ts:128 + drift re-arm :142 new cross-layer event field
R6-6a compression retry invisible llm-chat.ts:564 vs TryCompressOptions :588; call sites :3102/:3965; chatCompressionService.ts:844/:884; baseLlmClient.ts:78/:307/:462 new callback on exported GenerateTextOptions
R6-6b retainsPhysicalSlot unbounded background-tasks.ts:943, :1512, :1890, cancel() bail :984; PR's own invariant at :920-934 semantics + data-loss edge
R1-36 workspace MCP routes during drain bridge.ts:6318-6322, :14699/:14821/:14877 vs session twins :14927/:14936 routing contract + public API

Two of these produced new evidence this round that changes the shape of the decision:

  • R6-6b: the suggested remediation (an unref'd fallback timer in failUnresponsive calling releaseRetainedPhysicalSlot) contradicts this PR's own documented invariant at background-tasks.ts:920-934 — "the physical slot must still be retained … otherwise getRunningBackgroundCount and hasRunningTasks() free a concurrency slot that is still occupied, and /clear, /resume, /branch and session switches all proceed over live work." A delayed auto-release does exactly that once it fires, and reset() clears the map without aborting entry.abortController, so a still-running orphan loses its only owner. The stuck gate is real (cancel() bails at :984 on the failed status failUnresponsive set at :940; pruneTerminalEntries() exempts retained entries at :1890) — but both exits cost something, so the choice is not mechanical.
  • R1-36: the cheaper "refuse when liveChannelInfo() !== admissibleChannelInfo()" remediation is provably unreachable in the reported scenario, not merely ineffective. Both predicates read the same single channelInfo binding (:6318-6319 and :6321-6322), so they can only disagree when channelInfo is non-dying and not active — draining with no replacement spawned. After a recycle repoints channelInfo at the replacement both return the reference-identical object. A fan-out is feasible (aliveChannels is iterated at :2821, membership-tested at :4035); what blocks it is the partial-success contract on the workspace HTTP result and the conditional mcp_server_added broadcast.

Decisions requested

  1. R6-2 — bounded total grace covering the cooperative unwind (keeping the hard force-fail), or let a late cooperative settle re-publish its real payload (incl. the worktree suffix) and skip the recycle? The second crosses agent.ts + background-tasks.ts + Session.ts and inverts terminal-outcome precedence.
  2. R6-3 — should a nested run's provider-directed backoff extend the parent's tool deadline at all? If yes, retryDelayMs goes on AgentToolProgressEvent with a latch/clear rule and the MAX_RETRY_DEADLINE_EXTENSION_MS clamp re-passed through the drift re-arm closure.
  3. R6-6a — may a compression side query's backoff extend a background agent's model deadline, or must compression stay maxAttempts: 1 and fail to NOOP fast? chatCompressionService.ts:749-751 argues the latter; this PR's design doc argues the former.
  4. R6-6b — bounded automatic release (contradicts :920-934, can free a slot a live orphan still occupies), or user-initiated force-release in cancel() plus dropping the matching refusal in task-stop.ts?
  5. R1-36 — fan out to every non-dying generation and define partial success, or reject workspace-scoped runtime-MCP mutations whenever a work-owning generation is not the active one?

Resolution-integrity audit — 32 flagged, 0 re-opened

32 resolved threads whose first comment is [Critical] end with the bot's own finding and carry no author reply. Grouped by finding id that is 20 ids, and every one of them was minted on or before 2026-09-09T11:37:45Z. The bot's two later full-diff scans (2026-09-11T09:32, 2026-09-12T04:52) re-minted only R1-36, R6-2, R6-3 and R6-6, each with explicit "Still stands at <sha>" wording — i.e. the reviewer demonstrably re-mints anything that survives a scan. Non-re-mint across two later scans is therefore the retirement evidence, and none of the 20 is a silent resolve of a still-live Critical. The resolved threads that do come from those two later rounds all carry author replies with fix SHAs (1487c0d5b6 on background-tasks.ts and bridge.ts:3808; 90e2d6f7ae on retireWhenSessionsDrain).

CI — clean, nothing PR-caused to fix

gh pr checks 11270 at this head: 22 pass / 26 skipping / 1 pending. All required suites pass — Test (ubuntu-latest, Node 22.x) 22m23s, Lint & Static 12m38s, Integration Tests (no-AK, No Sandbox) 8m49s, Serve A/B 21m30s, web-shell E2E Smoke 10m13s, Real daemon E2E, TUI parity snapshots, OpenTUI no-flicker gate, Desktop Shell (ubuntu + windows). The single pending job is review-pr, an informational review bot that does not gate. No DFSAMPLE load failures and no checkout-verification race in any log, so no infra-vs-PR attribution was needed. The skipping rows are conditional jobs (fork-PR signalling, windows/macos test shards, takeover/retry command handlers) that this run did not select.

Why /triage was not re-triggered

The standing CHANGES_REQUESTED is qwen-code-ci-bot's (2026-09-12T04:52:05Z), which is normally the cue for a standalone @qwen-code /triage. It was deliberately skipped: the head is unchanged since that scan, so a re-run would re-mint the same four findings rather than re-judge anything, and the CR is not stale — the four Criticals are live and unresolved. It clears on a code change answering one of the five decisions above, or on a maintainer override.

No push this round; head remains 93eef769a7.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

  • isChannelLive() doc describes admission while the implementation reports liveness (packages/acp-bridge/src/bridgeTypes.ts:2531) — already reported (round-3 deferral record)
  • recordOnly terminal notification dropped in the interactive TUI (packages/cli/src/ui/hooks/use-llm-stream.ts:6314) — already reported as R1-9 (comment 3950282815)
  • BridgeRuntimeRecyclingError missing from the daemon metrics known-error allowlist (packages/acp-bridge/src/bridgeErrors.ts:676) — already reported as R1-10 (comment 3950282821)
  • the public AcpSessionBridge.requestRuntimeRecycle member has no production caller (packages/acp-bridge/src/bridge.ts:9783) — already reported as R1-11 (comment 3950282825)
  • the shipped child-to-daemon sessionRuntimeRecycle ext-method validation is untested (packages/acp-bridge/src/bridgeClient.ts:1340) — already reported as R1-11 (comment 3950282825)
  • wasReapPending is captured before two awaits so the rollback can erase an independent condemnation (packages/acp-bridge/src/bridge.ts:3813) — already reported (round-8 deferral, bridge.ts:3827)
  • isDying is declared a plain boolean but implemented as a write-only-true alias over state (packages/acp-bridge/src/bridge.ts:1098) — already reported (round-8 deferral, bridge.ts:1098)
  • the recycle's replacement generation is never handed to the channel idle-reap policy (packages/acp-bridge/src/bridge.ts:3816) — already reported as R3-10 (round-3 review 5142185428)
  • settled:true TOOL_PROGRESS is re-emitted for every terminal call on every scheduler notify (packages/core/src/agents/runtime/agent-core.ts:2188) — already reported (round-8 deferral, agent-core.ts:2188)
  • ToolDeadline.parkedOnInput is a dead switch (packages/core/src/agents/runtime/agent-progress-watchdog.ts:106) — already reported (round-8 deferral)
  • the escalation callback onUnresponsive is asserted nowhere (packages/core/src/agents/runtime/agent-progress-watchdog.test.ts:66) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • the suite never emits START/STREAM_TEXT/USAGE_METADATA so onActivity is unexercised (packages/core/src/agents/runtime/agent-progress-watchdog.test.ts:25) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • agent-core's producer-side watchdog emissions are untested (packages/core/src/agents/runtime/agent-core.ts:2104) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • the daemon's record-only drain branch has no test (packages/cli/src/acp-integration/session/Session.ts:10337) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • releaseRetainedPhysicalSlot and the retained-slot accounting have no test (packages/core/src/agents/background-tasks.ts:1348) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • the resume-path retainsPhysicalSlot early exits have no test (packages/core/src/agents/background-agent-resume.ts:1300) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • neither new runtime_recycling 503 mapping is tested (packages/cli/src/serve/acp-http/dispatch.ts:918) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • describeBlockingBackgroundWork's widened predicate and 'still stopping' label have no test (packages/cli/src/ui/utils/backgroundWorkUtils.ts:101) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • all agent.ts watchdog wiring is untested (packages/core/src/tools/agent/agent.ts:3881) — already reported in the missing-test aggregate (round-3 review 5142185428)
  • the new retryable 503 carries no Retry-After backoff hint and is absent from the error-taxonomy docs (packages/cli/src/serve/server/error-response.ts:383) — already reported (round-3 deferral record)
  • …and 2 more (see the run report)

Unresolved, please confirm:

  • [Critical] R6-1 (agent-progress-watchdog approval timeout) — @doudouOUC's review 5177234081 records it as still implemented and not fixed, while round 8's machine ledger does not carry it; this round did not re-read that mechanism, so it cannot be rul…
  • [Critical] R1-29 (cancel-versus-watchdog terminal semantics) — @doudouOUC's review 5177234081 states it must not be called resolved merely because a later test chooses the opposite policy, and round 8's ledger does not carry it; not re-read this round…

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were also skipped and the same unit suites ran on Linux, so only the Linux unit ground is covered.

Not reviewed: reverse audit — stopped after round 4 of the plan's 5-round cap; rounds 3 and 4 both reported findings, so two consecutive dry rounds were unreachable and the final round was not run.

Not reviewed: test-efficacy probe — Agent 7's mutation probe came back inconclusive because its scratch tree could not resolve the workspace packages' dist/, so no coverage gap was measured in either direction.

Not reviewed: reverse-audit rounds 3 and 4 Suggestion-level candidates — the loop was stopped before they could be put through a verifier, so they are disclosed as unverified rather than confirmed.

Not reviewed: reverse-audit round 4 Critical at packages/acp-bridge/src/bridge.ts:8836 — reported but never put through a verifier; carried in the findings artifact at low confidence.

Not explored to full depth (tool budget reached): chunk 1: did not verify whether any SDK/client-side layer retries an errorKind: 'internal' frame (finding 1's impact hinges on it), and did not read the restore re-che…; "agent reverse-audit (round 3)": the filed finding was traced by reading only — I did not execute a probe of the two-generation recycle sequence, which is why it carries Confidence: low .; "agent reverse-audit (round 1)": did not verify the child-side cost of the orphaned restored session in finding 1 — whether the agent-owned writer lease it holds ( packages/cli/src/acp-integrat….

Deferred under the convergence posture (round 9, not a blocker) — recorded, not requested in this round; 3 Critical(s) among them are deferred by their axes — fails-closed on new surface, where no wrong result is certified and the merge base had neither the surface nor the defect — and remain follow-up work recorded in the findings artifact:

  • packages/core/src/tools/agent/agent.ts:3915 — [probe] Critical [fails-closed] [new-surface] R6-6: a retained physical slot has no terminal remedy once the watchdog has failed the entry
  • packages/acp-bridge/src/bridge.ts:5711 — [probe] Critical [fails-closed] [new-surface] a recycle-condemned spawn rejection is classified as a non-retryable internal error while this PR adds a retryable 503 sibling
  • packages/acp-bridge/src/bridge.ts:4648 — [probe] Critical [fails-closed] [new-surface] the recycle cancels the current primary's idle reaper and never re-arms it
  • packages/acp-bridge/src/bridge.ts:5711 — [probe] a spawn rejected by the draining re-check leaves the child-side session unsettled
  • packages/core/src/agents/runtime/agent-progress-watchdog.ts:114 — [probe] the six-hour retry-extension cap is untested and an above-cap delay is reachable by default
  • packages/core/src/agents/runtime/agent-progress-watchdog.ts:204 — [probe] nothing pins that a heartbeating executing tool is exempt from the model deadline

Convergence: round 9 posted 4 inline comment(s), 1 of them reported for the first time; the previous round posted 6 (2 new). Findings keep coming back to the same files: packages/core/src/agents/background-tasks.ts (findings in round 8; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

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

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

未决,请确认:共 2 条(原文未翻译,列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally; Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were also skipped and the same unit suites ran on Linux, so only the Linux unit ground is covered.

未审查(原文为英文):reverse audit — stopped after round 4 of the plan's 5-round cap; rounds 3 and 4 both reported findings, so two consecutive dry rounds were unreachable and the final round was not run.

未审查(原文为英文):test-efficacy probe — Agent 7's mutation probe came back inconclusive because its scratch tree could not resolve the workspace packages' dist/, so no coverage gap was measured in either direction.

未审查(原文为英文):reverse-audit rounds 3 and 4 Suggestion-level candidates — the loop was stopped before they could be put through a verifier, so they are disclosed as unverified rather than confirmed.

未审查(原文为英文):reverse-audit round 4 Critical at packages/acp-bridge/src/bridge.ts:8836 — reported but never put through a verifier; carried in the findings artifact at low confidence.

未探索到全部深度(达到工具调用预算):chunk 1:did not verify whether any SDK/client-side layer retries an errorKind: 'internal' frame (finding 1's impact hinges on it), and did not read the restore re-che…"agent reverse-audit (round 3)"the filed finding was traced by reading only — I did not execute a probe of the two-generation recycle sequence, which is why it carries Confidence: low ."agent reverse-audit (round 1)"did not verify the child-side cost of the orphaned restored session in finding 1 — whether the agent-owned writer lease it holds ( packages/cli/src/acp-integrat…

收敛姿态下延后(第 9 轮,非阻断)——已记录,本轮不要求修改;其中 3 条 Critical 按其失败方向与对照基线延后——fails-closed 且 new-surface:未认证任何错误结果,且 merge base 既无该功能面也无该缺陷——作为后续工作记录在 findings 工件中:共 6 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 9 轮发布了 4 条行内评论,其中 1 条是首次提出;上一轮发布了 6 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/core/src/agents/background-tasks.ts(第 8 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

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

}
this.releaseFinishingWaiters(agentId, true);
this.rejectPendingApprovals(entry);
this.emitNotification(entry, true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] R9-1: [certifies-falsely] [new-surface] The runtime recycle is hung off the record-only notification, so whenever the cancel-grace timer wins the race the owner generation is never condemned — while the cost half of the escalation still lands.

failUnresponsive is deliberately not gated on entry.notified, and it latches retainsPhysicalSlot = true before calling emitNotification(entry, true). But emitNotification still returns at if (entry.notified) return;, and the recycle has exactly one trigger in the whole repository: Session.ts's if (meta.recordOnly) branch calling #recordUnresponsiveAgentNotification, whose finally sends qwen/control/session/runtime/recycle. Suppress the notification and the recycle goes with it.

Both timers are 5 s, and the escalation is drift-guarded and re-arms while the cancel grace is a bare setTimeout, so one event-loop stall is enough for the cancel side to win — the comment above this method says exactly that. Any cancel({ notify: false }) inside the window does the same (/clear, a session switch, ACP session teardown). When it happens the owner generation stays state: 'active', so admissibleChannelInfo() keeps handing fresh sessions to the child the design doc calls unsafe for fresh work, and no replacement spawns; meanwhile retainsPhysicalSlot keeps hasRunningTasks() true, so /clear, /resume, /branch and session switches refuse permanently — the only release is the run body's .finally, which by hypothesis never runs. The design doc promises the opposite order ("recorded and displayed … then the trusted child-to-daemon route requests recycle"), and issue #8586's Layer 5 requires both halves.

The in-diff rationale argues "Only the notification is suppressed". That is true of the user-visible line and does not account for the recycle bolted to the same call.

Witness:

probe against the real registry at HEAD:
ARM B (cancel grace finalized first)  {"newCallsFromEscalation":0,"recordOnlyCalls":0,"status":"failed","retainsPhysicalSlot":true,"hasRunningTasks":true}
ARM C (notified cleared, re-entered)  the recordOnly call appears — so `if (entry.notified) return;` is the sole suppressor
grep sessionRuntimeRecycle packages/: 3 hits — the constant, the child-side handler, and the single call site in #recordUnresponsiveAgentNotification's finally

Do not hang the recycle off the notification: request it from failUnresponsive directly, or from a path that does not consult entry.notified, so the generation is condemned whenever the escalation lands. If it must stay behind the notification, let the record-only path still invoke the recycle callback when entry.notified is already set.

Any fix must keep the user-visible notification idempotent — the comment at background-tasks.ts:921-935 states that emitNotification is itself idempotent (if (entry.notified) return), "so the already-delivered terminal notification is never re-fired", and a fix must not re-deliver a duplicate terminal line while adding the recycle.

Please extend background-tasks.test.ts's "retains the physical slot when the cancel grace timer finalizes before the escalation" case to assert the recycle is still requested in that ordering; it must go red while emitNotification short-circuits on entry.notified.

中文说明

[Critical] R9-1:运行时回收(recycle)挂在 record-only 通知上,因此只要 cancel-grace 定时器赢得竞争,owner generation 就永远不会被判定回收——而升级处理的代价那一半却照常发生。

failUnresponsive 有意不以 entry.notified 为门槛,并在调用 emitNotification(entry, true) 之前置上 retainsPhysicalSlot = true。但 emitNotification 仍然会在 if (entry.notified) return; 处返回,而整个仓库中回收只有一个触发点:Session.tsif (meta.recordOnly) 分支调用 #recordUnresponsiveAgentNotification,由其 finally 发出 qwen/control/session/runtime/recycle。通知被抑制,回收就一并被抑制。

两个定时器都是 5 秒,而升级定时器带漂移保护、会重新计时,cancel grace 却是裸 setTimeout,因此一次事件循环阻塞就足以让 cancel 一侧获胜——本方法上方的注释正是这么写的。窗口内任何 cancel({ notify: false }) 效果相同(/clear、切换会话、ACP 会话销毁)。一旦发生,owner generation 仍保持 state: 'active',于是 admissibleChannelInfo() 会继续把新会话交给设计文档称为"不适合承接新工作"的那个子进程,且不会派生替代 generation;同时 retainsPhysicalSlothasRunningTasks() 恒为真,/clear/resume/branch 与会话切换将被永久拒绝——唯一的释放点是 run body 的 .finally,而按前提它永远不会执行。设计文档承诺的是相反的顺序("先记录并展示……然后由可信的 child-to-daemon 路由请求回收"),issue #8586 的 Layer 5 也要求两半都成立。

diff 内的理由写的是"只有通知被抑制"。这对用户可见的那一行成立,但没有考虑到挂在同一次调用上的回收。

修复方向:不要把回收挂在通知上——直接从 failUnresponsive 请求回收,或从一个不读取 entry.notified 的路径请求,使升级一旦落地 generation 就被判定回收。若必须留在通知之后,则让 record-only 路径在 entry.notified 已置位时仍然调用回收回调。

修复约束:必须保持用户可见通知的幂等性——background-tasks.ts:921-935 的注释写明 emitNotification 自身是幂等的(if (entry.notified) return),"因此已送达的终态通知不会被再次触发";补上回收的同时不得重复投递终态通知。

请补充测试:扩展 background-tasks.test.ts 中 "retains the physical slot when the cancel grace timer finalizes before the escalation" 用例,断言在该顺序下回收请求仍然发出;当 emitNotification 仍在 entry.notified 处短路时,该断言必须为红。

(证据见上方 Witness 代码块;witness 为程序输出,未翻译。)

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

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.

5 participants