Skip to content

fix(channel): recover ACP bridge after wake - #8211

Merged
yiliang114 merged 9 commits into
QwenLM:mainfrom
yiliang114:fix/channel-wake-recovery
Aug 1, 2026
Merged

fix(channel): recover ACP bridge after wake#8211
yiliang114 merged 9 commits into
QwenLM:mainfrom
yiliang114:fix/channel-wake-recovery

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

TLDR

Recover channel ACP bridges that become unusable after a long host sleep or an active event-loop stall, without reconnecting the messaging adapter.

Before this change, the channel process could stay online while its ACP child was wedged or disconnected, so inbound messages and scheduled work could remain queued against a dead bridge. After this change, the channel start paths rebuild only the ACP bridge, preserve channel connectivity, and hold inbound, loop, and webhook prompt boundaries until the replacement bridge is ready.

What this changes

  • Treat a long low-CPU scheduling gap as host suspension, reset the lag histogram, and avoid killing a healthy child immediately after wake.
  • Keep active high-CPU event-loop stalls recoverable by terminating the wedged ACP child.
  • Coalesce standalone and shared-channel bridge recovery, including disconnects from a replacement bridge while recovery is in progress.
  • Gate session routing and bridge capture for inbound messages, scheduled loops, and webhook tasks until recovery completes.
  • Preserve the existing channel adapter connection and session routing during ACP replacement.

Design Consideration

Recovery is scoped to the ACP bridge. Reconnecting the channel adapter would duplicate platform connections and can lose adapter-owned state; the 99% path is that the messaging connection remains healthy while only the local ACP child needs replacement.

The readiness gate stays blocked across chained recovery attempts and is released only after a replacement bridge initializes successfully or recovery stops after a terminal failure.

Reviewer Test Plan

npx vitest run packages/channels/base/src/ChannelBase.test.ts packages/cli/src/commands/channel/start.test.ts packages/core/src/telemetry/event-loop-lag.test.ts packages/channels/base/src/SessionRouter.test.ts packages/channels/base/src/AcpBridge.test.ts --maxWorkers 1 --no-file-parallelism

npx prettier --check packages/channels/base/src/ChannelBase.ts packages/channels/base/src/ChannelBase.test.ts packages/cli/src/commands/channel/start.ts packages/cli/src/commands/channel/start.test.ts packages/core/src/telemetry/event-loop-lag.ts packages/core/src/telemetry/event-loop-lag.test.ts

npx eslint packages/channels/base/src/ChannelBase.ts packages/channels/base/src/ChannelBase.test.ts packages/cli/src/commands/channel/start.ts packages/cli/src/commands/channel/start.test.ts packages/core/src/telemetry/event-loop-lag.ts packages/core/src/telemetry/event-loop-lag.test.ts --max-warnings 0

npx tsc --noEmit -p packages/core/tsconfig.json
npx tsc --noEmit -p packages/channels/base/tsconfig.json
npx tsc --noEmit -p packages/cli/tsconfig.json
npm run build

Focused result: 729 tests passed. Static checks and full build passed.

Linked issues / bugs

Follow-up to #6329. That issue captured the wedged-but-alive ACP bridge failure; this PR also handles host wake without misclassifying suspension as an active stall.

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 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

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: the substance is all here, though the headings deviate from the template (TLDR / What this changes / Design Consideration instead of What this PR does / Why it's needed, and there's no Risk & Scope section). Not blocking — the what/why/test-plan/linked-issue content is all present — but worth a Risk & Scope note (main tradeoff, what's not validated) for a recovery path this intricate.

Problem: observed, not theoretical. This is a follow-up to #6329 ("Recover DingTalk channel when ACP bridge stalls but bot process stays alive"), a real closed bug. The new angle is concrete too: after a long host sleep, the event-loop-lag monitor sees a huge scheduling gap and the ACP child gets killed on wake even though it was healthy. The mechanism is real — AcpBridge.maybeKillOnEventLoopStall parses the [perf] acp agent event loop stall stderr line from acpAgent.ts and SIGKILLs the child.

Direction: aligned. Scoped recovery of just the ACP bridge (keeping the messaging adapter connection alive) is the right call — reconnecting the adapter would duplicate platform connections and drop adapter-owned state. The suspension-vs-stall split (long gap + low CPU = sleep; long gap + high CPU = real stall) is a clean way to stop misclassifying wake as a stall.

Size: cross-package (channels/base, cli, core/telemetry) but only ~316 production lines (ChannelBase.ts 24, start.ts 208, event-loop-lag.ts 84) vs ~565 test lines — under the 500-line awareness threshold. The core touch is additive (new options with safe defaults) and I traced its consumers: the ACP agent's stall log (→ the child kill this fixes), and the daemon's stall warning + metrics gauge. No escalation needed.

Approach: scope feels right — host-suspension detection, bridge-only recovery with chained-disconnect coalescing, and a readiness gate held across inbound/loop/webhook boundaries are each needed for the stated goal, and I don't see a materially simpler path. One thing to consider: recoverBridge is now duplicated almost verbatim between startSingle and startAll (the disconnect handler was already duplicated before, but this widens it) — a shared helper would keep the two recovery paths from drifting.

Risk: no elevated risk signals — none of the changed files match the revert-correlated path list.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板: 内容齐全,但小标题与模板不一致(用 TLDR / What this changes / Design Consideration 代替 What this PR does / Why it's needed,且缺少 Risk & Scope 一节)。不阻塞——what/why/测试计划/关联 issue 的信息都在——但这条恢复链路相当复杂,建议补一段 Risk & Scope(主要权衡、未验证的部分)。

问题: 已观测到的真实问题,非理论性加固。这是 #6329("ACP bridge 卡死但进程存活时恢复钉钉渠道",已关闭的真实 bug)的后续。新增的角度也很具体:长时间休眠后,event-loop-lag 监控看到一个巨大的调度间隙,ACP 子进程在唤醒时被误杀,尽管它是健康的。机制真实存在——AcpBridge.maybeKillOnEventLoopStall 解析 acpAgent.ts 输出的 [perf] acp agent event loop stall stderr 行并 SIGKILL 子进程。

方向: 对齐。把恢复范围限定在 ACP bridge(保持消息适配器连接存活)是正确的——重连适配器会复制平台连接并丢失适配器持有的状态。用"长间隙 + 低 CPU = 休眠;长间隙 + 高 CPU = 真实卡顿"来区分休眠与卡顿,是避免把唤醒误判为卡顿的干净做法。

规模: 跨包(channels/base、cli、core/telemetry),但生产代码仅约 316 行(ChannelBase.ts 24、start.ts 208、event-loop-lag.ts 84),测试约 565 行——低于 500 行的关注阈值。对 core 的改动是增量式的(带安全默认值的新选项),我已追溯其消费方:ACP agent 的卡顿日志(→ 本 PR 要修复的子进程误杀)、以及 daemon 的卡顿告警 + 指标 gauge。无需升级。

方案: 范围合理——休眠检测、仅替换 bridge 并合并链式断连、以及在 inbound/loop/webhook 边界保持就绪门控,这三者对所述目标都是必需的,我没有看到明显更简的路径。一点建议:recoverBridge 现在在 startSinglestartAll 之间几乎逐字重复(断连处理器此前就已重复,但本 PR 扩大了重复面)——抽一个共享 helper 可以避免两条恢复路径日后各自漂移。

风险: 无升级风险信号——改动的文件均未命中与 revert 相关的高风险路径列表。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Code review

I worked through the diff against my own take on how this should be built, and the implementation lands where I'd expect. No correctness blockers — the concurrency handling is the part most likely to be wrong, and it's the part that's right:

  • The readiness gate is only released after the replacement bridge is started and wired into the router/channels, so anything that awaited the gate captures the new bridge. In ChannelBase, every waitForBridgeRecovery() call sits immediately before const promptBridge = this.bridge with no await in between, and there are rechecks after the await points (memory recall, contact recording, queue wait) where a recovery could have started mid-flight. That closes the obvious races.
  • Chained disconnects coalesce correctly: a disconnect of the current bridge during recovery sets recoveryRequested and the do…while loops again; a disconnect of a superseded bridge is ignored; the crash counter is pushed once per iteration so a repeatedly-failing replacement still trips MAX_CRASH_RESTARTS and exits.
  • Dropping the scheduler?.stop()/start() pair from the recovery path is safe because loop prompts now block at the gate instead — the scheduler keeps running and its prompts wait out the swap rather than firing at a dead bridge.
  • The suspension heuristic fails open: when CPU accounting is unavailable (calculateCpuRatioundefined) it reports the stall rather than suppressing it, so a real stall can't be hidden by missing CPU data.

Two non-blocking notes:

  • recoverBridge is now duplicated near-verbatim between startSingle and startAll (the disconnect handler was already duplicated; this widens the surface). Worth extracting a shared helper so the two recovery paths don't drift — same sentiment as Stage 1.
  • snapshot() now calls checkHistogram(), so a metrics read can reset the histogram after a suspension and return zeros until fresh samples accrue. That's the intended behavior (drop the bogus sleep sample), just worth knowing for anyone reading the daemon gauge.
Recovery flow
sequenceDiagram
    participant P1 as AcpBridge
    participant P2 as start.ts recovery
    participant P3 as Readiness gate
    participant P4 as ChannelBase
    P1->>P2: disconnected
    P2->>P3: block
    P4->>P3: inbound, loop, webhook wait
    P2->>P1: new bridge, start, rewire router and channels
    P2->>P3: release
    P3-->>P4: proceed on new bridge
Loading
Files changed (6)
File What changed
packages/cli/src/commands/channel/start.ts Bridge-only recovery with a readiness gate and chained-disconnect coalescing; no longer reconnects the channel adapters
packages/channels/base/src/ChannelBase.ts New bridgeRecovery barrier, awaited before session routing and bridge capture on the inbound, loop, and webhook paths
packages/core/src/telemetry/event-loop-lag.ts Host-suspension detection via a CPU-time ratio; long low-CPU gaps reset the histogram instead of firing a stall
packages/cli/src/commands/channel/start.test.ts Covers standalone and shared recovery, coalescing, and the gate staying blocked across chained disconnects
packages/channels/base/src/ChannelBase.test.ts Covers the gate holding inbound, loop, and webhook prompts until recovery completes
packages/core/src/telemetry/event-loop-lag.test.ts Covers suspension reset, real-stall reporting, and the CPU-unavailable fail-open path

Testing

This is an unattended CI run, so I have not built or executed any PR code — the signal below is the PR's own CI on the reviewed commit, read through the API. The Linux unit suite is still running at the time of writing; the finalize job updates the table once it settles. No failures on any completed check so far.

Final CI results for a1097d2 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

The unit tests pin each layer of the change individually — suspension-vs-stall classification (including the CPU-unavailable fail-open), recovery coalescing, the gate staying blocked across chained disconnects, and the gate holding each prompt boundary. What they don't exercise is the full chain end to end under real conditions.

Sandboxed verification would settle this: @qwen-code /verify — that a real long low-CPU gap (simulated host wake) actually suppresses the [perf] acp agent event loop stall line and therefore the AcpBridge child kill, while a genuine high-CPU stall still triggers kill-and-recover. The unit tests pin each half of that chain separately, but the wiring between the lag monitor's stderr line and the bridge kill is only demonstrated by the author's description, not by CI. This is a sponsored run (the author doesn't have write access): a maintainer's @qwen-code /verify approves the head it's written against and carries a pre-execution risk screen plus a workspace wipe — read the resulting report with the same skepticism as the fork's own CI logs, since the code under verification is adversarial input.

中文说明

代码审查

我对照自己的实现思路通读了 diff,结论是 PR 的做法与我的预期一致。没有正确性阻塞项——最容易出错的并发处理恰恰是做对的部分:

  • 就绪门只在替换 bridge 启动接入 router/channels 之后才释放,因此等待门控的逻辑捕获到的是新 bridge。在 ChannelBase 中,每个 waitForBridgeRecovery() 都紧贴在 const promptBridge = this.bridge 之前、中间没有 await,并且在那些可能中途触发恢复的 await 点(内存读取、联系人记录、队列等待)之后都有复查。这堵住了明显的竞态。
  • 链式断连的合并是正确的:恢复期间当前 bridge 断连会置位 recoveryRequested 并让 do…while 再循环一次;已被替换的旧 bridge 断连则被忽略;崩溃计数每轮循环递增一次,因此反复失败的替换仍会触发 MAX_CRASH_RESTARTS 并退出。
  • 从恢复路径中去掉 scheduler?.stop()/start() 是安全的,因为 loop 提示词现在会在门控处阻塞——调度器保持运行,其提示词会等待 bridge 替换完成,而不是对着已死的 bridge 触发。
  • 休眠启发式采用"失败即上报"策略:当 CPU 统计不可用(calculateCpuRatio 返回 undefined)时,会上报卡顿而非抑制,因此真实的卡顿不会因为缺少 CPU 数据而被隐藏。

两点非阻塞建议:

  • recoverBridge 现在在 startSinglestartAll 之间几乎逐字重复(断连处理器此前就已重复,本 PR 扩大了重复面)。建议抽出共享 helper,避免两条恢复路径日后漂移——与 Stage 1 的意见一致。
  • snapshot() 现在会调用 checkHistogram(),因此一次指标读取可能在休眠后重置直方图,并在有新样本前返回 0。这是预期行为(丢弃失真的休眠样本),但读取 daemon gauge 时需要知道这一点。

测试

这是无人值守的 CI 运行,因此我没有构建或执行任何 PR 代码——以下信号是 PR 自身在被审 commit 上的 CI,通过 API 读取。撰写时 Linux 单元测试套件仍在运行;finalize 任务会在其结束后更新表格。目前已完成的检查中没有任何失败。

单元测试分别钉住了改动的每一层——休眠与卡顿的分类(含 CPU 不可用时的失败即上报)、恢复合并、门控在链式断连中保持阻塞、以及门控对每个提示词边界的拦截。未被覆盖的是真实条件下端到端的完整链路。

沙箱验证可以补齐这一点:@qwen-code /verify——验证一次真实的长间隙低 CPU(模拟主机唤醒)确实能抑制 [perf] acp agent event loop stall 行、从而避免 AcpBridge 杀子进程,而真实的高 CPU 卡顿仍会触发杀进程并恢复。单元测试分别钉住了这条链路的两半,但 lag 监控的 stderr 行与 bridge 杀进程之间的接线,只由作者描述证明,CI 并未演示。这是一次 sponsored run(作者没有写权限):maintainer 的 @qwen-code /verify 会批准其撰写时所对的 head,并带有执行前风险筛查与工作区清理——请像对待 fork 自身 CI 日志一样审慎地阅读其报告,因为被验证的代码是对抗性输入。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — a well-constructed fix for a real, observed failure; only non-blocking nits (the duplicated recovery code, and an end-to-end chain that's assembled from separately-pinned unit tests rather than one integration test).

Stepping back: this is the work of someone who clearly knows the channel recovery paths. The design decision to replace only the ACP bridge and keep the messaging adapter alive is the right call, and the suspension-vs-stall split is a clean way to stop killing a healthy child on host wake. My independent take on how to build this converged on the same three pieces — CPU-ratio suspension detection, bridge-only recovery with coalescing, and a readiness gate at the prompt boundaries — so I'm endorsing the approach, not just failing to find a better one.

The concurrency is where a PR like this lives or dies, and it holds up: the gate releases only after the replacement is wired in, there's no await between the gate check and the bridge capture, and the coalescing distinguishes current-bridge disconnects (re-loop) from superseded-bridge disconnects (ignore). The CPU heuristic fails open when CPU accounting is unavailable, which is the safe direction.

What keeps this from a clean 5/5 is just that the full chain — real wake → suppressed kill → recovered channel — is what /verify would demonstrate; CI proves the halves individually, not the wiring. Non-blocking, but it's the honest reason for the withheld point. The near-duplicate recoverBridge in startSingle/startAll is worth a follow-up extraction, also non-blocking.

Approval is deferred until CI lands green on a1097d21471277824cb8cd544ac5fb8503ab2bad — the Linux unit suite is still running, so I'm leaving the commit-pinned approval for the finalize step rather than attesting to a result that doesn't exist yet.

中文说明

置信度:4/5 —— 针对一个真实、已观测到的故障的良好修复;只有非阻塞的小问题(重复的恢复代码,以及一条由分别钉住的单元测试拼装、而非单一集成测试覆盖的端到端链路)。

退一步看:这显然出自熟悉渠道恢复路径的人之手。只替换 ACP bridge、保持消息适配器存活的设计决策是正确的,而用休眠/卡顿的区分来避免在主机唤醒时误杀健康子进程,是一个干净的做法。我对如何实现的独立判断与这三部分一致——基于 CPU 比例的休眠检测、带合并的仅替换 bridge、以及在提示词边界设置就绪门控——因此我是认可这个方案,而不仅仅是没找到更好的。

并发处理是这类 PR 成败的关键,而它站得住脚:门控只在替换 bridge 接入后才释放,门控检查与 bridge 捕获之间没有 await,合并逻辑能区分当前 bridge 的断连(重新循环)与已被替换 bridge 的断连(忽略)。CPU 启发式在 CPU 统计不可用时失败即上报,这是安全的方向。

之所以不是干净的 5/5,仅因为完整链路——真实唤醒 → 抑制杀进程 → 渠道恢复——正是 /verify 所要证明的;CI 分别证明了链路的两半,而非其接线。非阻塞,但这是扣一分的诚实理由。startSingle/startAll 中近乎重复的 recoverBridge 值得后续抽取,同样非阻塞。

批准将推迟到 CI 在 a1097d21471277824cb8cd544ac5fb8503ab2bad 上变绿之后——Linux 单元测试套件仍在运行,因此我把绑定 commit 的批准留给 finalize 步骤,而不是为一个尚不存在的结果背书。

Qwen Code · qwen3.8-max-preview

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

Reviewed — no blockers. Suggestions are inline.

— qwen3.8-max-preview via Qwen Code /review

Comment thread packages/channels/base/src/ChannelBase.ts
Comment thread packages/channels/base/src/ChannelBase.ts
@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /takeover

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

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Review

Reviewed the diff against a local checkout of fix/channel-wake-recovery (deps installed, focused suites run, plus mutation probes). The core design is right: recovering only the ACP bridge and holding prompt boundaries instead of tearing down the platform connection is clearly better than the old disconnect()/connect() churn, and dropping the No channels reconnected. Exiting. branch removes a real false-exit. The CPU-ratio suspension heuristic is also sound, including the deliberate fail-open when process.cpuUsage() throws or elapsedMs === 0, and Date.now() (rather than a monotonic clock) is the correct basis for detecting host suspension.

Findings below, most significant first. All of them were verified by running code, not by reading alone.


1. Most of the new ChannelBase gate tests do not test the gate

I ran two mutations against packages/channels/base/src/ChannelBase.test.ts:

Mutation A — keep waitForBridgeRecovery() but make it non-blocking (void bridgeRecovery instead of await bridgeRecovery): 7 of the 8 new tests still pass. Only rechecks bridge recovery before a queued followup prompt starts fails.

Mutation B — delete all 7 await this.waitForBridgeRecovery() call sites outright: 3 still pass:

  • rechecks bridge recovery before a webhook prompt starts
  • rechecks bridge recovery before a loop prompt starts
  • waits for bridge recovery after adapter-specific preflight

Those three are vacuous under any mutation. Note what that means for coverage: the two "recheck immediately before the prompt" gates (ChannelBase.ts:1619, ChannelBase.ts:1916) are the actual fix for "queue wait and memory recall can outlive a bridge crash", and no test fails when they are removed.

Root cause is the assertion timing. The tests assert expect(bridge.prompt).not.toHaveBeenCalled() after a single await Promise.resolve(), which is never enough microtask progress for the ungated flow to reach bridge.prompt() either — so the assertion holds for the wrong reason. The test then releases the gate and awaits completion, which succeeds with or without the gate.

Suggested fix: drive each flow to a genuinely quiescent point before asserting (repeated await new Promise((r) => setImmediate(r)), or vi.waitFor on a downstream observable such as router.resolve / readChannelMemory), and pair each test with a control that shows the same flow does reach bridge.prompt() when no recovery is pending. rechecks bridge recovery before a queued followup prompt starts is the right shape — it is the one test that survives both mutations.

2. A bridge crash now records a failure against in-flight loop jobs (can auto-disable them)

Removing scheduler?.stop() / scheduler?.start() from the recovery path (start.ts) also removed their side effects. ChannelLoopScheduler.stop() does this.generation++ and this.inFlightJobs.clear(), and fire()'s catch block relies on that:

if (this.generation !== generation || !currentJob?.enabled) {
  await this.clearRunningSince(latestJob.id, runningSince);
  return;                       // <- old path: crash != failure
}
await this.recordFailure(currentJob, now, ...);

Previously the generation bump fired synchronously on disconnected, before RESTART_DELAY_MS, so a loop whose bridge.prompt() was in flight when the ACP child was SIGKILLed took the clearRunningSince branch. Now the generation never changes, the rejected/timed-out prompt falls through to recordFailure(), and:

  • consecutiveFailures++, auto-disabling the loop at maxConsecutiveFailures (default 5), and
  • for recurring: false, patch.enabled = false on the first occurrence — a one-shot scheduled loop is silently dropped by a bridge crash.

The gate can only protect runs that have not yet reached prompt(); runs already inside it still die with the child. Worth distinguishing "failed because the bridge was replaced under it" from "the agent failed" — e.g. ChannelLoopSkippedError, or an explicit recovery epoch the scheduler can compare against.

3. Suspend threshold (10 min) sits above the kill threshold (5 min), so 5–10 minute sleeps still kill a healthy child

DEFAULT_SUSPEND_THRESHOLD_MS = 10 * 60 * 1_000 (event-loop-lag.ts:36) but AcpBridge.ts:45 has ACP_EVENT_LOOP_STALL_RESTART_MS = 5 * 60 * 1000. Any gap in [5min, 10min) is below the suspension filter, so it is still reported and still SIGKILLs the child. Verified:

// zero CPU across the gap, default suspendThresholdMs
histogram.max = 360_000_000_000;              // 6 minutes
// => histogram.reset NOT called, onNewMaxStall(360_000) fired
// => 360_000 >= ACP_EVENT_LOOP_STALL_RESTART_MS -> child killed

A 6-minute lid-close or VM pause is at least as common as a >10-minute one. Either set suspendThresholdMs <= ACP_EVENT_LOOP_STALL_RESTART_MS, or apply the CPU-ratio test to any gap above stallThresholdMs and let the ratio do the classifying.

4. The CPU ratio is measured over the wrong window, so a genuine long stall gets wiped one tick later

cpuRatio covers [lastCheckTimeMs, now], but maxMs is a sticky histogram max from an earlier window. After a real CPU-burning 600 s stall is correctly reported on the first tick, the next tick measures a 20 ms idle window (ratio ≈ 0) against the same stale maxMs and classifies it as suspension. Verified: histogram.reset() fires on tick 2 and lastReportedMaxMs is zeroed, so the worst stalls are erased from snapshot() almost immediately and a later, smaller stall re-reports as a "new max".

Requiring elapsedMs >= suspendThresholdMs as well (the gap being classified must be the gap just measured) fixes this without changing the wake-up case, where elapsedMs ≈ maxMs by construction.

5. snapshot() is no longer side-effect free

snapshot() now calls checkHistogram() (event-loop-lag.ts:103), which can both invoke onNewMaxStall and histogram.reset() the data it is about to return. Verified: calling snapshot() with histogram.max = 400_000_000_000 fires onNewMaxStall(400_000) with no timer tick at all.

That matters because run-qwen-serve.ts:3241 registers snapshot() as a metrics gauge callback via registerDaemonEventLoopLagGauge. A metrics scrape can now emit a stall warning and zero the histogram it is sampling; in the ACP agent, the same callback shape is what leads to a SIGKILL. Consider keeping snapshot() read-only (cache the classification computed by the interval) or at minimum not firing onNewMaxStall from the snapshot path.

Related: the interval is now created unconditionally (event-loop-lag.ts:98), where before it existed only when onNewMaxStall was supplied. Both current callers pass the callback so there is no live regression, but every future snapshot()-only caller now pays Date.now() + process.cpuUsage() + a histogram read every 20 ms.

6. coalesces duplicate standalone disconnect events into one recovery does not verify coalescing

The handler is now synchronous and returns void, so await Promise.all([firstRestart, secondRestart]) is Promise.all([undefined, undefined]) — it awaits nothing. The assertions land mid-flight, before the second round's RESTART_DELAY_MS elapses. Advancing the fake timers by a further 3000 ms in that same test shows the second round does run:

mockAcpBridge            -> 3 calls   (test asserts 2)
mockRouterRestoreSessions -> 2 calls   (test asserts 1)

The underlying logic issue is if (failedBridge === bridge) recoveryRequested = true (start.ts:267, start.ts:474). During RESTART_DELAY_MS, bridge still points at the failed bridge, so a repeat event from the already-dead bridge is mis-attributed as "the current bridge died again" — an extra restart round plus an extra crashTimestamps.push(), which counts toward MAX_CRASH_RESTARTS = 3 and can process.exit(1) the whole channel service.

AcpBridge emits disconnected exactly once per child exit, so this is not reachable today, but the guard does not express the invariant it intends. A monotonically increasing bridge generation (compare failedGeneration === currentGeneration) would, and would let the test run the timers to completion and assert real coalescing. The sibling test keeps the readiness gate blocked and coalesces replacement disconnects is genuinely good and does exercise the chained path — worth extending it rather than keeping the weaker one.

7. The readiness gate is unbounded

bridgeReadiness has no timeout, and AcpBridge has no request timeout on loadSession / newSession / prompt (only permission requests are timed out, AcpBridge.ts:441). If a replacement child starts but wedges without exiting — the exact failure class in #6329router.restoreSessions() never settles, the recovery task never settles, finally never runs, and every inbound message, loop job and webhook task blocks forever with no user-visible signal. Under startAll that is all channels at once. Before this PR those messages at least reached bridge.prompt() and could surface an error.

Worth a Promise.race bound on waitForBridgeRecovery(), and/or telling the user "reconnecting…" once the wait crosses a threshold. Related: runLoopPrompt's timeoutMs only wraps promptBridge.prompt(), so the gate wait is entirely outside the loop timeout.

8. Nits

  • createBridgeReadinessGate + recoverBridge is now ~70 duplicated lines across startSingle and startAll, with more state than before (recoveryTask, recoveryRequested, bridgeReadiness). Extracting it would keep the two paths from drifting.
  • attachDisconnectHandler(bridge) now runs before await bridge.start(). That is required for replacement-disconnect coalescing, but it means a start() failure both sets recoveryRequested and throws to the .catchprocess.exit(1), so the flag is dead on that path. A one-line comment would save the next reader the trace.
  • Scope note: bridgeRecovery is only wired in commands/channel/start.ts. daemon-worker.ts builds channels over createDaemonChannelBridgeFacade (in-process, no ACP child), so it is correctly out of scope — but a sentence in the PR body would make that explicit.

Overall: direction and structure are good, and the keeps the readiness gate blocked test is the kind of coverage the rest of the change needs. I'd want #1 and #2 addressed before merge — #1 because the fix currently ships without regression protection on its two most important gates, and #2 because it can silently disable a user's scheduled loops. #3 is a small constant change that materially widens the class of sleeps the PR actually fixes.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

— qwen3.8-max-preview via Qwen Code /review

Comment thread packages/channels/base/src/ChannelBase.test.ts
…upe recovery

- Lower the default event-loop suspend threshold to 5 minutes so any
  low-CPU sleep gap is filtered before it reaches the AcpBridge
  stall-kill threshold; pin the invariant with a cross-package test.
- Extract the duplicated startSingle/startAll bridge recovery into a
  shared createBridgeRecovery helper.
@qwen-code-dev-bot

qwen-code-dev-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

AutoFix round 3 finishedview run. See this round's report below.

中文说明

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

yiliang114 and others added 2 commits July 31, 2026 19:34
The mock provided a record method but ChannelBaseOptions.observedContacts
expects observe, so the slow-contact-recording path was never exercised.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

— qwen3.8-max-preview via Qwen Code /review

Comment thread packages/cli/src/acp-integration/stall-thresholds.test.ts Outdated
Comment thread packages/core/src/telemetry/event-loop-lag.test.ts Outdated
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Addressed both threshold-test findings in ec51dfc8ad0.

  • Reused the existing ACP monitor call test to compare the actual configured suspend threshold with the bridge restart threshold, and removed the redundant proxy-constant test.
  • Renamed the below-threshold event-loop test to match its asserted report behavior.
  • Verified the channel-base build, both focused Vitest cases, Prettier, ESLint, and git diff --check.

No production logic or public API changed. No screenshot is included because this is unit-test-only evidence.

中文说明

已在 ec51dfc8ad0 处理两条 threshold 测试意见:复用现有 ACP monitor 调用测试校验实际 suspend threshold 与 bridge restart threshold,删除错误的 proxy 常量测试,并更正低于阈值用例名称。channel-base 构建、两个 focused Vitest、Prettier、ESLint 和 git diff --check 均通过。未改生产逻辑或公共 API;纯单元测试不附截图。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下:

Autofix review round summary — PR #8211

This round addresses the two findings the maintainer review explicitly named as
wanted-before-merge (#1 vacuous gate tests, #2 loop jobs aborted by recovery
recorded as failures), plus the actionable inline suggestions. Several other
review points were already resolved by earlier commits on this branch; each is
listed below with its current-state evidence.

Changes made this round

#2 — Bridge crash no longer records a failure against in-flight loop jobs (production fix)

Recovery no longer stops/starts the scheduler, so the generation bump that used
to protect in-flight jobs is gone; a loop whose bridge.prompt() was in flight
when the ACP child was killed fell through to recordFailure(), bumping
consecutiveFailures (auto-disable at 5) and disabling one-shot loops on the
first crash. Added an explicit recovery epoch the scheduler compares against, as
the reviewer suggested:

  • ChannelLoopScheduler: new recoveryEpoch counter and markBridgeRecovery();
    fire() captures the epoch at start and treats a changed epoch in the catch as
    a clear-running-state (skip), not a failure.
  • start.ts: recoverBridge() calls scheduler?.markBridgeRecovery()
    synchronously when recovery starts. This is race-safe: on child exit,
    AcpBridge emits disconnected synchronously (before the in-flight prompt's
    rejection settles asynchronously via stream-close), so the epoch is bumped
    before fire()'s catch runs. The readiness gate stays blocked for the whole
    recovery, so no new prompts start mid-recovery — one bump per recovery covers
    exactly the aborted in-flight runs.
  • New regression test verifies an in-flight loop aborted by recovery is cleared
    with no consecutiveFailures bump, no error status, and no disable. Verified
    mutation-surviving: removing the epoch check makes the test fail.

#1 — Gate tests now actually test the gates (test fix)

The recheck/entry gate tests asserted expect(bridge.prompt).not.toHaveBeenCalled()
after a single await Promise.resolve(), which is never enough progress for the
ungated flow to reach bridge.prompt() either — so the assertion held for the
wrong reason (the reviewer's mutation B left them passing with the gates
deleted). Strengthened the four vacuous gates (webhook entry, webhook recheck,
adapter-specific preflight, loop recheck) to use a bounded negative wait: absent
the gate the fully-mocked flow reaches bridge.prompt quickly, so waiting 500 ms
without it being called proves the gate held. The existing release-then-assert
already acts as the positive control for a broken flow. Verified mutation-surviving:
removing the loop recheck gate now makes its test fail (previously it passed).

Inline suggestions

  • rc:3689456556 (webhook entry gate): strengthened "waits for bridge recovery
    before resolving a webhook session" as above.
  • rc:3689456548 (followup-prompt gate at ~5519): already covered by the
    dedicated, mutation-surviving test "rechecks bridge recovery before a queued
    followup prompt starts" (the one test the reviewer confirmed survives both
    mutations). No change needed.
  • rc:3690025120 (observedContacts mock recordobserve): already fixed in
    the current code (the recovery-gate mock uses observe).
  • rc:3690463364 / rc:3690463389 (threshold-test constant and test name):
    already addressed in ec51dfc8a.

Already resolved by earlier commits (verified against current code)

  • 如何自定义密钥文件 .env可能与其他文件冲突 #3 suspend threshold above kill threshold: DEFAULT_EVENT_LOOP_SUSPEND_THRESHOLD_MS
    is now 5 * 60 * 1000, kept at or below ACP_EVENT_LOOP_STALL_RESTART_MS.
  • Are you interested in AI Terminal? #4 CPU ratio over the wrong window: the suspension filter now also requires
    elapsedMs >= suspendThresholdMs, so the gap being classified is the gap just
    measured.
  • TypeError in Authentication Selection Interface #5 snapshot() side effects: snapshot() is read-only (no checkHistogram()
    call); classification happens only on the interval tick.
  • OpenAI API Error: 401 Incorecct API Key provided #6 coalescing: recoverySourceBridge attribution means a repeat event from
    the already-dead bridge is ignored (not re-attributed as a new crash), and the
    coalesces duplicate standalone disconnect events… test now runs the fake
    timers to completion and asserts real counts (mockAcpBridge 2, restoreSessions 1).
  • report error when try to auth #8 duplication: recovery logic is extracted into shared createBridgeRecovery
    / createBridgeReadinessGate; attachDisconnectHandler is attached after
    bridge.start() succeeds, so the dead-flag concern no longer applies.

Open question for the maintainer (not resolved this round)

#7 — unbounded readiness gate. waitForBridgeRecovery() has no timeout, and
the reviewer suggested a Promise.race bound and/or a "reconnecting…" notice.
This is a failure-semantics/product decision rather than a clear defect, so I am
leaving it for the maintainer rather than picking a direction:

  • The catastrophic hang the reviewer describes (a wedged replacement child) is
    already bounded on the restore step by BRIDGE_SESSION_RESTORE_TIMEOUT_MS
    (60 s), which tears the service down on expiry rather than blocking forever.
  • Adding a timeout to the gate itself forces a tradeoff: on expiry, either fail
    the inbound/loop/webhook message, or proceed against a possibly-dead bridge —
    the latter is exactly the regression this PR's gates exist to prevent.
  • The "tell the user reconnecting…" part is a UX/product call.

Recommendation: if desired, add a user-visible "reconnecting…" notice once the
wait crosses a threshold (no behavior change), and keep the gate itself unbounded
so messages are never routed to a dead bridge. Happy to implement either bound
the maintainer prefers. (Scope note from #8: bridgeRecovery is intentionally
wired only in commands/channel/start.ts; daemon-worker.ts uses the in-process
createDaemonChannelBridgeFacade with no ACP child, so it is correctly out of
scope.)

Conflict notes

--conflict false; no merge performed.

Verification

  • npm run build — passed (exit 0)
  • npm run typecheck — passed (exit 0)
  • npm run lint — passed (exit 0)
  • npx prettier --check on the five touched files — passed ("All matched files use Prettier code style!")
  • vitest run src/ChannelLoopScheduler.test.ts (packages/channels/base, touched) — 24 passed (includes the new Where is the config saved? #2 test)
  • vitest run src/ChannelBase.test.ts (packages/channels/base, touched) — 559 passed (includes the four strengthened gate tests)
  • vitest run src/commands/channel/start.test.ts (packages/cli, touched) — 31 passed
  • Mutation probes (temporary, reverted): removing the scheduler epoch check fails the new Where is the config saved? #2 test; removing the loop recheck gate fails its strengthened test — confirming both guard the intended behavior.
  • Integration tests: not run. The touched behavior (scheduler recovery classification and ChannelBase gate timing) is internal logic fully exercised by the unit suites above, not something only reachable through the bundled CLI/integration harness.
中文说明

Autofix 审查轮次总结 — PR #8211

本轮处理维护者审查中明确要求在合并前解决的两条意见(#1 门控测试形同虚设、#2 被恢复中断的循环任务被记为失败),以及可操作的行内建议。其余若干审查点已由本分支更早的提交解决;下面逐条列出其当前代码证据。

本轮改动

#2 — 桥接崩溃不再把被中断的进行中循环任务记为失败(生产修复)

恢复路径不再 stop/start 调度器,因此过去用于保护进行中任务的 generation 自增副作用消失了;当 ACP 子进程被杀死时,某个正处于 bridge.prompt() 中的循环会落入 recordFailure(),累加 consecutiveFailures(达到 5 次自动禁用),并使一次性循环在首次崩溃时即被禁用。按审查者建议,新增一个供调度器比对的恢复 epoch:

  • ChannelLoopScheduler:新增 recoveryEpoch 计数器与 markBridgeRecovery()fire() 在开始时捕获 epoch,并在 catch 中将 epoch 变化视为清除运行态(跳过)而非失败。
  • start.tsrecoverBridge() 在恢复开始时同步调用 scheduler?.markBridgeRecovery()。这是竞态安全的:子进程 exit 时,AcpBridge 会同步发出 disconnected(早于进行中 prompt 经由流关闭异步落定的拒绝),因此 epoch 在 fire() 的 catch 运行之前就已自增。整个恢复期间就绪门保持阻塞,不会有新 prompt 在恢复中途启动——每次恢复一次自增恰好覆盖被中断的进行中运行。
  • 新增回归测试验证:被恢复中断的进行中循环会被清除,不累加 consecutiveFailures、不置错误状态、不禁用。已验证可捕获突变:移除 epoch 检查会使该测试失败。

#1 — 门控测试现在真正测试了门控(测试修复)

这些 recheck/entry 门控测试在单次 await Promise.resolve() 之后就断言 expect(bridge.prompt).not.toHaveBeenCalled(),而这对于无门控流程而言也远不足以推进到 bridge.prompt()——所以断言因错误的原因成立(审查者的突变 B 在删除门控后它们仍通过)。将四个形同虚设的门控(webhook entry、webhook recheck、adapter-specific preflight、loop recheck)改为有界负向等待:若无门控,全 mock 的流程会很快到达 bridge.prompt,因此在 500 ms 内它未被调用即可证明门控生效。既有的“释放后再断言已被调用”充当了对流程损坏的正向对照。已验证可捕获突变:移除 loop recheck 门控后其测试现在会失败(此前会通过)。

行内建议

  • rc:3689456556(webhook entry 门控):按上述方式加固了 “waits for bridge recovery before resolving a webhook session”。
  • rc:3689456548(约 5519 行的 followup-prompt 门控):已由专门的、可捕获突变的测试 “rechecks bridge recovery before a queued followup prompt starts” 覆盖(审查者确认这是唯一在两种突变下都存活的测试)。无需改动。
  • rc:3690025120observedContacts mock 的 recordobserve):当前代码已修复(恢复门控 mock 使用 observe)。
  • rc:3690463364 / rc:3690463389(阈值测试常量与测试名):已在 ec51dfc8a 中处理。

已由更早提交解决(已对当前代码核验)

留给维护者的开放问题(本轮不解决)

#7 — 无界的就绪门。 waitForBridgeRecovery() 没有超时,审查者建议加 Promise.race 界限和/或 “reconnecting…” 提示。这属于失败语义/产品决策而非明确缺陷,故留给维护者而非自行选定方向:

  • 审查者所述的灾难性挂起(替换子进程卡死)在 restore 步骤上已由 BRIDGE_SESSION_RESTORE_TIMEOUT_MS(60 秒)限界,超时即拆除服务而非永久阻塞。
  • 给门本身加超时会迫使一个取舍:到期后要么让入站/循环/webhook 消息失败,要么继续对可能已死的桥接发 prompt——后者正是本 PR 门控所要防止的回归。
  • “告知用户 reconnecting…” 部分属于 UX/产品决策。

建议:如需要,可在等待超过阈值时增加一个对用户可见的 “reconnecting…” 提示(不改变行为),同时保持门本身无界,使消息永远不会被路由到已死的桥接。若维护者倾向某种界限,乐于实现。(#8 的范围说明:bridgeRecovery 有意只在 commands/channel/start.ts 中接线;daemon-worker.ts 使用进程内的 createDaemonChannelBridgeFacade,无 ACP 子进程,因此正确地不在范围内。)

冲突说明

--conflict false;未执行合并。

验证

  • npm run build — 通过(exit 0)
  • npm run typecheck — 通过(exit 0)
  • npm run lint — 通过(exit 0)
  • 对五个改动文件运行 npx prettier --check — 通过(“All matched files use Prettier code style!”)
  • vitest run src/ChannelLoopScheduler.test.ts(packages/channels/base,改动)— 24 通过(含新增的 Where is the config saved? #2 测试)
  • vitest run src/ChannelBase.test.ts(packages/channels/base,改动)— 559 通过(含四个加固后的门控测试)
  • vitest run src/commands/channel/start.test.ts(packages/cli,改动)— 31 通过
  • 突变探针(临时、已回退):移除调度器 epoch 检查会使新增的 Where is the config saved? #2 测试失败;移除 loop recheck 门控会使加固后的测试失败——确认两者都守护了预期行为。
  • 集成测试:未运行。改动的行为(调度器恢复分类与 ChannelBase 门控时序)是内部逻辑,已由上述单元测试套件完整覆盖,并非只能通过打包后的 CLI/集成测试台触达。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Improve channel resilience by rebuilding only the ACP bridge when it wedges (e.g., after long host sleep or prolonged event-loop stalls), while keeping channel adapters connected and ensuring inbound/loop/webhook prompt boundaries wait until bridge recovery completes.

Changes:

  • Add host-suspension-aware event-loop lag monitoring (CPU-ratio-based) and export the default suspend threshold.
  • Refactor channel start crash-recovery to rebuild ACP bridges without reconnecting adapters, and add a readiness gate to hold session routing/prompt capture until recovery completes.
  • Add ACP bridge startup timeout handling, plus scheduler logic to avoid counting recovery-aborted loop prompts as agent failures, with expanded test coverage.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/core/src/telemetry/index.ts Re-export the default event-loop suspend threshold constant.
packages/core/src/telemetry/event-loop-lag.ts Add suspension filtering using CPU usage ratio; always run a background histogram check loop.
packages/core/src/telemetry/event-loop-lag.test.ts Add tests covering suspension filtering, snapshot purity, and CPU-unavailable behavior.
packages/cli/src/commands/channel/start.ts Introduce a bridge readiness gate and unified ACP bridge recovery that preserves adapter connectivity.
packages/cli/src/commands/channel/start.test.ts Add tests ensuring bridge recovery coalescing, readiness gating, and failure cleanup without adapter reconnects.
packages/cli/src/acp-integration/acpAgent.ts Configure ACP agent lag monitor to treat long low-CPU gaps as suspension.
packages/cli/src/acp-integration/acpAgent.test.ts Assert ACP agent lag monitor wiring includes the suspend threshold setting.
packages/channels/base/src/index.ts Re-export ACP_EVENT_LOOP_STALL_RESTART_MS from AcpBridge.
packages/channels/base/src/ChannelLoopScheduler.ts Add recovery epoch tracking to avoid counting recovery-aborted prompts as failures.
packages/channels/base/src/ChannelLoopScheduler.test.ts Add test for recovery-aborted in-flight loop prompt handling.
packages/channels/base/src/ChannelBase.ts Add bridgeRecovery option and gate session routing / prompt bridge capture on recovery completion.
packages/channels/base/src/ChannelBase.test.ts Add tests asserting inbound/webhook/loop paths wait for recovery and re-check after preprocessing.
packages/channels/base/src/AcpBridge.ts Add ACP initialization timeout and ensure child is stopped on startup failure.
packages/channels/base/src/AcpBridge.test.ts Add test that initialization timeout rejects and kills the ACP child process.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/cli/src/acp-integration/acpAgent.ts Outdated
@yiliang114
yiliang114 enabled auto-merge July 31, 2026 15:42
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Resolved the remaining suggestion without a code change.

The duplicated value is already protected by the ACP agent test, which compares the monitor option directly against ACP_EVENT_LOOP_STALL_RESTART_MS from channel-base. A future drift makes that test fail. Importing the production constant here would therefore be a deduplication refactor, not a current correctness fix, so I am not expanding this closeout.

中文说明

剩余建议已 resolve,未改代码。现有 ACP agent 测试已经直接用 channel-base 的 ACP_EVENT_LOOP_STALL_RESTART_MS 校验 monitor 参数,未来数值漂移会使测试失败;改为生产代码跨模块导入只属于去重重构,不是当前缺陷。

doudouOUC
doudouOUC previously approved these changes Jul 31, 2026

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

Reviewed at b8baca2. The highest-risk part of this change — the readiness gate's await-before-capture ordering — is correct at every site, which I checked individually rather than in aggregate. All 8 waitForBridgeRecovery() call sites gate strictly before the bridge reference is taken, including the two inside the queued prompt continuations where a stale capture would be invisible:

await this.waitForBridgeRecovery();
const promptBridge = this.bridge;

That holds for the loop path (ChannelBase.ts:1623) and the webhook path (:1920), and the outer entries (:1472, :1791) gate before router.resolve, so no consumer can route into or prompt a dead bridge. The gate itself re-loops on a newly installed barrier (bridgeRecovery === completedRecovery is the exit condition, not first-await), so a recovery that starts while a consumer is already waiting is also awaited.

Recovery coalescing is sound: a disconnect arriving mid-recovery from the replacement bridge sets recoveryRequested instead of spawning a second task (failedBridge !== recoverySourceBridge), and the do…while (recoveryRequested && !isShuttingDown()) loop chains it into the same task, so the gate stays blocked across the whole chain. Release is in .finally guarded on recoveryTask === task; the two paths that skip release both process.exit(1), and the crash-window trim can't spin (the just-pushed timestamp always terminates the shift loop), so I found no path where the gate never settles. Adapter preservation checks out — the recovery body only constructs the bridge, re-points router.setBridge / channel.setBridge, and re-registers the four relays; nothing calls the adapter's connect/disconnect, and tests assert connect stays at one call.

The suspend-vs-stall discriminator's units are right (process.cpuUsage() is µs, elapsedMs * 1_000 converts the window to µs, so the ratio is dimensionless), and the reasoning holds where it matters: an active stall blocks the interval callback too, so elapsedMs and newMaxMs both cross the threshold in both scenarios — CPU ratio is the only real discriminator, and a CPU-spinning wedge lands far above 1% and is still reported and killed.

Two non-blocking notes:

  1. The discriminator has one hole worth knowing about: it keys on wall-clock Date.now() deltas plus CPU idleness, so a ≥5-minute event-loop block that is not CPU-bound — a blocking syscall on a hung network/FUSE mount, an attached debugger, a SIGSTOP/resume — is indistinguishable from host suspension and gets filtered, leaving that wedge un-killed. That is the #6329 class this PR follows up on, narrowed rather than reopened (the common spin-wedge is still caught), and the trade is deliberate per the PR body. If you want it closed later, comparing the wall-clock delta against a monotonic (process.hrtime.bigint()) delta separates the two cleanly: only real suspension advances wall clock without advancing CLOCK_MONOTONIC.

  2. The open copilot thread on acpAgent.ts:350 still stands at this HEAD — ACP_EVENT_LOOP_SUSPEND_THRESHOLD_MS = 5 * 60 * 1000 is still re-declared while the comment says "Match the parent channel bridge's restart threshold". I confirmed the suggested fix is feasible: channel-base exports ACP_EVENT_LOOP_STALL_RESTART_MS from its package entry at this HEAD, and packages/cli already depends on @qwen-code/channel-base and imports from it in start.ts, so a package-entry import is available and house-style-clean. Worth taking, because the test asserts only numeric equality against the imported constant — it would stay green if the two drifted in the same direction, and would only catch a one-sided edit.

Ran the reviewer test plan locally at this HEAD: event-loop-lag 15/15, channels/base (ChannelBase + AcpBridge + ChannelLoopScheduler) 619/619, start 31/31, acpAgent 353/353. Note for anyone reproducing: acpAgent.test.ts fails with suspendThresholdMs: undefined against a stale packages/channels/base/dist — this PR adds ACP_EVENT_LOOP_STALL_RESTART_MS to the package entry and the test imports it through the built module, so rebuild channel-base first. Not a defect; CI builds before testing. CI green apart from review-pr still running.

if (this.generation !== generation || !currentJob?.enabled) {
if (
this.generation !== generation ||
recoveryEpoch !== this.recoveryEpoch ||

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.

[P1] Scope recovery suppression to the prompt it actually aborted

recoveryEpoch is captured before await this.store.update(...) and before runLoopPrompt(). If bridge recovery starts while that write is pending, runLoopPrompt() begins afterward, waits for the recovery gate, and can then fail for a genuine reason on the replacement bridge. This epoch mismatch still clears only runningSince and returns, even though recovery did not abort that prompt. Because lastFiredAt was already advanced, the failure becomes invisible: no error status or failure count is recorded, a recurring occurrence is lost, and a one-shot job remains eligible instead of recording its failed attempt.

I reproduced this deterministically by blocking the initial store update, calling markBridgeRecovery(), releasing the write, and then rejecting the runner: the only follow-up patch was { runningSince: undefined }; no failure patch was emitted. Please scope the marker to runs actually aborted by that recovery rather than every run spanning an epoch change. The new test marks recovery from inside an already-running prompt before throwing, so it does not cover this post-recovery failure case.

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed exact head b8baca2a4b. I found one new correctness issue and left it inline: the scheduler-wide recovery epoch can also suppress a genuine failure from a prompt that starts after recovery and runs on the replacement bridge. The reproduction advances lastFiredAt but emits only a runningSince clear, with no error/failure accounting. I recommend fixing that before merge.

Outside that finding, I traced the added options and recovery consumers, rechecked the current review threads, and found the bridge-only replacement, readiness gate, chained-disconnect coalescing, startup/restore bounds, and suspension filter internally consistent at this head.

Verification: ChannelLoopScheduler.test.ts + ChannelBase.test.ts (583 passed), AcpBridge.test.ts (36 passed), event-loop-lag.test.ts (15 passed), and start.test.ts (31 passed). The changed acpAgent threshold assertion also passes when its package alias is resolved to this PR worktree; the full local file otherwise picked up the main checkout build through the shared dependency symlink, so I did not treat that environment-only mismatch as a PR failure.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +146 to +148
it('times out bridge initialization and stops the child', async () => {
vi.useFakeTimers();
child.setInitializeImplementation(() => new Promise(() => {}));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Fake timers are enabled here with vi.useFakeTimers() but restored only by a trailing vi.useRealTimers() at the end of the test body, not in a finally. — Concrete cost: if any assertion before that line fails (e.g. the rejection message or the kill assertion regresses), vi.useRealTimers() is never reached and fake timers leak into the subsequent tests in this file. Later tests call bridge.start(), which awaits a real setTimeout(resolve, 1000) (AcpBridge.ts:148); under leaked fake timers that never fires, so the next test hangs until the vitest timeout and masks the real failure. The other fake-timer test in this same file already uses the safe try { … } finally { vi.useRealTimers(); } pattern.

Wrap the body in try { … } finally { vi.useRealTimers(); }:

it('times out bridge initialization and stops the child', async () => {
  vi.useFakeTimers();
  try {
    // …existing body…
  } finally {
    vi.useRealTimers();
  }
});

— qwen3.8-max-preview via Qwen Code /review

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Review (round 2 — b8baca2a4)

My previous review was against 6d2498da5. I re-checked every finding from that round by running code in a worktree of fix/channel-wake-recovery, not by reading the diff.

The follow-ups resolved 7 of the 8 findings, several of them exactly as suggested:

Prior finding Status
1. Gate tests were vacuous Fixed — mutation now kills 8/9 (was 1/8)
2. Bridge crash recorded a failure against in-flight loops FixedmarkBridgeRecovery() / recoveryEpoch
3. Suspend threshold (10 min) above kill threshold (5 min) Fixed — both now 5 min
4. CPU ratio measured over the wrong window FixedelapsedMs >= suspendThresholdMs added
5. snapshot() had side effects Fixed — snapshot is read-only again, with tests
6. failedBridge === bridge mis-attribution FixedrecoverySourceBridge
7. Readiness gate unbounded FixedACP_START_TIMEOUT_MS + BRIDGE_SESSION_RESTORE_TIMEOUT_MS
8. ~70 duplicated lines across the two start paths FixedcreateBridgeRecovery

Good round. The remaining items are below, most significant first.


1. The fix for #4 made suspension classification nondeterministic

Adding elapsedMs >= suspendThresholdMs (event-loop-lag.ts:86-87) does fix the stale-max problem, but it now requires both pieces of evidence — the starved interval (elapsedMs) and the new histogram max — to be visible on the same tick. Nothing orders monitorEventLoopDelay's internal sample against our setInterval callback, so which tick the max lands on is a coin flip.

I drove the PR's and main's event-loop-lag.ts directly from a standalone harness with scaled thresholds (resolutionMs: 20, stallThresholdMs: 500, suspendThresholdMs: 2000, 3 s block — a 5-minute threshold isn't practical to exercise; the race is threshold-independent). 10 runs each:

Block type main this PR
SIGSTOP (process frozen — host-suspend proxy, 0.08 % CPU) reports 10/10 filtered 8/10, reports 2/10
Atomics.wait (event loop blocked, 0.05 % CPU) reports 10/10 reports 7/10, filtered 3/10
busy loop (100 % CPU) reports 10/10 reports 10/10 ✓

So on ~20 % of wakes the healthy child is still reported as a 5-minute-plus stall and SIGKILLed — the exact outcome the PR exists to prevent.

Instrumenting the decision points shows the two tick patterns:

filtered (correct):  [{elapsedMs:3020, maxMs:3020, newMaxMs:3020, ratio:0.00008}]

reported (wrong):    [{elapsedMs:3017, maxMs:  20, newMaxMs:   0, ratio:0.00049},   <- interval ran before
                      {elapsedMs:  20, maxMs:3003, newMaxMs:3003, ratio:0.00430}]   <-   the histogram published

On tick 1 the histogram hasn't published yet, so newMaxMs is 0 and nothing is classified — but lastCheckTimeMs is consumed. On tick 2 the max is there and the CPU ratio is still 0.4 %, yet elapsedMs is back to 20 ms, so the suspend branch is skipped and the gap is reported as an active stall.

The unit tests can't catch this because they assign histogram.max directly under fake timers, so the gap and the max are always visible together.

Minimal fix — keep the verdict sticky instead of requiring same-tick agreement:

let suspendGraceUntilMs = 0;
// ...
if (
  elapsedMs >= suspendThresholdMs &&
  cpuRatio !== undefined &&
  cpuRatio <= suspendCpuRatio
) {
  // The starved interval is the evidence. The histogram may not publish the
  // matching sample until a later tick, so keep the verdict for a moment
  // instead of requiring both signals on the same tick.
  suspendGraceUntilMs = nowMs + Math.max(resolutionMs * 5, 1_000);
}
if (newMaxMs >= suspendThresholdMs && nowMs <= suspendGraceUntilMs) {
  histogram.reset();
  lastObservedMaxMs = 0;
  lastReportedMaxMs = 0;
  suspendGraceUntilMs = 0;
  return;
}

I applied this and re-ran: all 15 existing event-loop-lag.test.ts tests still pass (including does not suppress an old histogram max after a short idle check, whose tick-1 ratio is 3.3 % so no grace window opens), and the probe becomes deterministic — SIGSTOP filtered 10/10, busy loop reported 10/10.

Worth adding a regression test that raises histogram.max on the tick after the clock jump; that is the case the current suite cannot express.

2. …which exposes that the CPU ratio alone cannot identify a wedge

Making the classification deterministic also makes the blind spot deterministic. Measured ratios over a 3 s gap:

SIGSTOP (frozen)               0.00077
Atomics.wait (blocked loop)    0.00047
blocking read() on a FIFO      0.00202
busy loop                      1.00027

The first three are indistinguishable, so "host suspended" and "event loop blocked in a low-CPU syscall" are the same observation. Today's coin flip eventually kills a low-CPU wedge; with the sticky fix such a child would be filtered indefinitely. That matters because #6329's symptom — process alive, sessions frozen, stall maxima climbing 917 s → 925 s → 952 s — is fully compatible with a low-CPU wedge.

So the sticky window and a positive liveness signal are both needed; neither alone is sufficient. The cheapest addition: when the parent filters a gap as suspension, have AcpBridge follow up with a lightweight ACP round-trip on a short timeout and kill the child if it doesn't answer. That only runs after a filtered gap, and it turns the heuristic from "guess" into "guess, then verify" — which is what #6329's acceptance criterion ("detection or recovery for a stuck bridge where the process remains alive") actually asks for. Wall-clock-vs-monotonic divergence is a cleaner signal for true S3 sleep, but I confirmed it does not help here: across SIGSTOP, Date.now() and process.hrtime.bigint() both advanced 4503 ms.

3. One gate test is still vacuous

Re-running both mutations from last round against the current tests:

  • Mutation A (await this.waitForBridgeRecovery()void …, 7 sites): kills 8/9 (was 1/8)
  • Mutation B (delete all 7 call sites): kills 8/9 (was 5/8)

The single survivor under both is rechecks bridge recovery after inbound preprocessing has started (ChannelBase.test.ts:16197). Line 16215 is the problem:

await vi.waitFor(() => expect(bridge.newSession).not.toHaveBeenCalled());

vi.waitFor on a negative assertion succeeds on its first poll, so it synchronises nothing. The later await Promise.resolve() is a single microtask, which is not enough for the ungated flow to reach bridge.prompt either — so both assertions hold whether or not the gate exists. The pattern the other eight tests now use works here:

await expect(
  vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalled(), { timeout: 500, interval: 25 }),
).rejects.toThrow();

4. A genuine loop failure during recovery is now recorded nowhere

ChannelLoopScheduler.ts:230 — if any recovery started during the run, the catch path calls clearRunningSince and returns. Not bumping consecutiveFailures is right, but a real failure (model error, prompt timeout) that merely coincides with a recovery now leaves no lastStatus: 'error', no lastError, and no stderr line. That is a debuggability regression in exactly the scenario #6329 was filed about. Suggest persisting lastStatus/lastError while skipping the consecutiveFailures increment.

Minor: markBridgeRecovery() fires once per recoverBridge() call, not once per do…while iteration, so a chained recovery shares one epoch bump. Harmless today because the readiness gate spans the chain, but worth a comment.

5. start() bounds initialize but not registerChannelLoopMcpServer

AcpBridge.ts:191registerChannelLoopMcpServer() awaits connection.extMethod(...), whose .catch handles rejection but not a promise that never settles against a wedged child. It can't hang today (a freshly constructed bridge has no channelLoopMcpServer until registerChannelLoopToolHandler runs, which is after start()), but it is one reordering away from an unbounded await bridge.start() holding bridgeReadiness blocked forever with every inbound message queued behind it. Cheap to fold into the same withTimeout.

6. Three copies of the 5-minute threshold, and the new export has no consumers

  • ACP_EVENT_LOOP_STALL_RESTART_MSAcpBridge.ts:45, the kill threshold
  • DEFAULT_EVENT_LOOP_SUSPEND_THRESHOLD_MS — newly exported from core/telemetry/index.ts, zero importers anywhere in src/
  • ACP_EVENT_LOOP_SUSPEND_THRESHOLD_MSacpAgent.ts:350, a hardcoded literal whose comment says "Match the parent channel bridge's restart threshold"

The PR also newly exports ACP_EVENT_LOOP_STALL_RESTART_MS from @qwen-code/channel-base's index, and the only consumer is acpAgent.test.ts:21. packages/cli already depends on @qwen-code/channel-base (package.json:49), so acpAgent.ts can import the real constant instead of duplicating the literal — the invariant would then be enforced by the compiler rather than by a test asserting one constant equals another. Either do that, or drop the unused core export.

7. The daemon's event-loop telemetry is now silently reset on wake

run-qwen-serve.ts:3235 starts a monitor with no suspendThresholdMs, so it inherits the 5-minute default, and its snapshot() feeds registerDaemonEventLoopLagGauge plus the Status metrics ring. When a suspension is detected, histogram.reset() zeroes mean/p50/p99/max — confirmed in the probe (snapshotMaxMs drops to 20 after filtering). That is probably desirable, but it is an unannounced behaviour change for daemon telemetry that the daemon opted into by omission. Worth an explicit value at the call site or a line in the PR body.

Related, still open from last round: the interval is now created unconditionally (event-loop-lag.ts:109), where before it existed only when onNewMaxStall was supplied. Both current callers pass one, so there is no live regression, but any future snapshot()-only caller now pays Date.now() + process.cpuUsage() + a histogram read 50×/s.

8. The invariant comment overstates the code

ChannelBase.ts:354-355 says "every path that resolves a session or calls the bridge must await waitForBridgeRecovery() immediately before that operation", but respondToPermission (506, 547, 732, 2754, 2798), cancelSession (2130, 2143, 4541, 5258) and availableCommands (3092) have no gate. Most are either downstream of the processInbound entry gate or are responses to events originating from the current bridge, so I don't think any of them is a bug — but as written the comment will mislead the next reader in both directions. Narrowing it to the session-routing and prompt-capture paths it actually describes would be more useful.


Things I checked that are fine

  • The chained-recovery coalescing is sound. I looked specifically for a window where a disconnected arriving between the do…while re-check and the .finally release would be dropped: there isn't one, because those are separated only by microtasks while disconnected originates from a child exit macrotask.
  • The crash-window rewrite (while (now - crashTimestamps[0] >= CRASH_WINDOW_MS) shift()) preserves the old < semantics and can neither spin nor deref undefined, since now is pushed first.
  • Both withTimeout and restoreBridgeSessions use Promise.race, so the loser's rejection is still handled — no unhandled-rejection risk.
  • Not reconnecting the adapter is the right call and the tests pin it (mockChannelConnect 2 → 1).

Verdict

The design is right and this round fixed nearly everything from the last one. I'd want #1 addressed before merge — a 20 % chance of killing a healthy child on every wake defeats the PR's headline goal, and the fix is a few lines that leave the existing suite green. #3 is small and closes the last hole in the regression protection. #2 is the more interesting one and could reasonably be a follow-up, but it should be written down somewhere rather than left implicit, because it decides whether #6329 is actually closed.

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 2/100 轮)。改动内容与我反驳保留之处如下:

Round summary

Addressed both inline findings.

[P1] Scope recovery suppression to the prompt it actually aborted (rc:3691879065)

Decision: Fixed.

The recoveryEpoch was captured before await this.store.update(...), so if bridge recovery started during that write, the prompt would begin afterward on the replacement bridge with a stale epoch. A genuine failure from that post-recovery prompt was then suppressed by the epoch mismatch check, losing error accounting.

Fix: Re-read this.recoveryEpoch after the store update completes and the generation check passes, immediately before runLoopPrompt(). The initial pre-try assignment is kept as a default for errors thrown by the store update itself (where the prompt never ran). This scopes suppression to prompts that were actually running when recovery started:

  • Recovery during store.update → epoch refreshed → post-recovery failures recorded ✓
  • Recovery during runLoopPrompt → epoch mismatches → failure suppressed (aborted by recovery) ✓

Added a regression test that blocks the store update, calls markBridgeRecovery(), releases the write, then rejects the runner — asserting that a failure patch with lastStatus: 'error' is emitted.

[Suggestion] Fake timer cleanup in AcpBridge.test.ts (rc:3691926626)

Decision: Fixed.

Wrapped the test body in try { … } finally { vi.useRealTimers(); }, matching the pattern already used by the other fake-timer test in the same file. Prevents fake timers from leaking into subsequent tests if an assertion fails mid-body.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • vitest run src/ChannelLoopScheduler.test.ts (packages/channels/base) — 25 passed
  • vitest run src/AcpBridge.test.ts (packages/channels/base) — 36 passed
  • vitest run src/ChannelBase.test.ts (packages/channels/base) — 559 passed
  • vitest run src/commands/channel/start.test.ts (packages/cli) — 31 passed
中文说明

本轮总结

处理了两条行内反馈。

[P1] 将恢复抑制限定到实际被中止的 prompt(rc:3691879065)

决定: 已修复。

recoveryEpoch 之前在 await this.store.update(...) 之前捕获,因此如果桥接恢复在该写入期间开始,prompt 会在恢复之后才在替换桥接上启动,但持有过时的 epoch。该恢复后 prompt 的真实失败会被 epoch 不匹配检查抑制,导致错误计数丢失。

修复: 在 store update 完成且 generation 检查通过后、runLoopPrompt() 调用前重新读取 this.recoveryEpoch。try 之前的初始赋值保留为默认值,用于 store update 本身抛出错误的情况(此时 prompt 从未运行)。这将抑制范围限定到恢复开始时实际正在运行的 prompt:

  • 恢复发生在 store.update 期间 → epoch 被刷新 → 恢复后的失败被记录 ✓
  • 恢复发生在 runLoopPrompt 期间 → epoch 不匹配 → 失败被抑制(被恢复中止)✓

新增回归测试:阻塞 store update,调用 markBridgeRecovery(),释放写入,然后让 runner 拒绝——断言发出包含 lastStatus: 'error' 的失败补丁。

[建议] AcpBridge.test.ts 中的假定时器清理(rc:3691926626)

决定: 已修复。

将测试体包裹在 try { … } finally { vi.useRealTimers(); } 中,与同文件中另一个假定时器测试已使用的模式一致。防止断言在测试体中间失败时假定时器泄漏到后续测试。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • vitest run src/ChannelLoopScheduler.test.ts(packages/channels/base)— 25 通过
  • vitest run src/AcpBridge.test.ts(packages/channels/base)— 36 通过
  • vitest run src/ChannelBase.test.ts(packages/channels/base)— 559 通过
  • vitest run src/commands/channel/start.test.ts(packages/cli)— 31 通过

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Review: fix(channel): recover ACP bridge after wake

Overview

Three separable pieces:

  1. event-loop-lag.ts — classify a long low-CPU scheduling gap as host suspension, reset the histogram instead of reporting it as a stall.
  2. start.ts — collapse the two near-identical ~90-line disconnected handlers into one createBridgeRecovery(), rebuild only the ACP bridge (adapters stay connected), coalesce chained disconnects, and gate inbound/loop/webhook work on a readiness barrier.
  3. ChannelBase / ChannelLoopSchedulerwaitForBridgeRecovery() at every session-resolution and bridge-capture point, plus a recoveryEpoch so a prompt aborted by the bridge swap isn't counted as an agent failure.

Parts 2 and 3 are good work and I'd take them largely as-is. Part 1 — the headline "don't kill a healthy child after wake" fix — does not work at runtime; details and a measured fix below.

Verified locally at 3ed3a9f3f7 in a clean worktree of the PR head:

Suite Result
packages/core telemetry/event-loop-lag.test.ts 15/15 pass
packages/channels/base AcpBridge + ChannelLoopScheduler 61/61 pass
packages/channels/base ChannelBase.test.ts 559/559 pass
packages/cli commands/channel/start.test.ts 31/31 pass
prettier --check, eslint --max-warnings 0 on the touched files clean

🔴 Blocker — the wake-suppression path is a two-timer race and loses about half the time

checkHistogram() (packages/core/src/telemetry/event-loop-lag.ts:85-95) requires newMaxMs >= suspendThresholdMs and elapsedMs >= suspendThresholdMs on the same tick. Those two numbers come from two independent libuv timers: elapsedMs from this module's own setInterval (line 109), histogram.max from the timer inside monitorEventLoopDelay. After a resume both are overdue and the order they fire in is not fixed. When our interval wins, the gap lands in the histogram one tick later:

tick N   : elapsedMs=2020  histogram.max=20    -> newMaxMs=0     -> suppression skipped
tick N+1 : elapsedMs=21    histogram.max=2017  -> newMaxMs=2017  -> elapsedMs check fails

The two conditions never co-occur, the wake gap is reported as a stall, and AcpBridge.maybeKillOnEventLoopStall SIGKILLs the healthy child anyway — the exact outcome the PR sets out to prevent.

Measured, driving the real startEventLoopLagMonitor from this branch in a child process frozen with SIGSTOP for 2000 ms (suspendThresholdMs: 1000, resolutionMs: 20 — production's default resolution):

build runs that reported a stall (two independent batches)
PR head 7 / 12, then 9 / 12
head + one-tick carry (below) 0 / 12, then 0 / 12

The rate varies because the failure is the race; what's stable is that head loses it most of the time and the patched build never does.

Negative controls on the patched build, which must still report:

scenario result
active stall 2000 ms @ 100% CPU reported 2009 ms ✅
active stall 700 ms (below suspend threshold) reported 710 ms ✅

Repro (elag.mjs = esbuild bundle of the PR's event-loop-lag.ts):

// victim.mjs — freeze me from outside with SIGSTOP
const reported = [];
const m = startEventLoopLagMonitor({ resolutionMs: 20, stallThresholdMs: 500,
  suspendThresholdMs: 1000, onNewMaxStall: (x) => reported.push(Math.round(x)) });
setTimeout(() => { m.dispose(); console.log(JSON.stringify({ reported })); }, 4000);

// driver.mjs
const c = spawn(process.execPath, ['victim.mjs'], { stdio: ['ignore','pipe','inherit'] });
await sleep(500); process.kill(c.pid, 'SIGSTOP');
await sleep(2000); process.kill(c.pid, 'SIGCONT');   // reported=[] is the pass

Suggested fix — latch the low-CPU gap so it stays eligible for one more tick. This is the build measured at 0/12 above:

   let lastCpuUsage = safeCpuUsage();
+  let pendingSuspendGapMs = 0;
@@
-    if (
-      newMaxMs >= suspendThresholdMs &&
-      elapsedMs >= suspendThresholdMs &&
-      cpuRatio !== undefined &&
-      cpuRatio <= suspendCpuRatio
-    ) {
+    const isLowCpuGap =
+      elapsedMs >= suspendThresholdMs &&
+      cpuRatio !== undefined &&
+      cpuRatio <= suspendCpuRatio;
+    // The histogram's own libuv timer can land the gap one tick after ours, so
+    // a low-CPU gap stays eligible for one further check.
+    const suspendGapMs = isLowCpuGap ? elapsedMs : pendingSuspendGapMs;
+    pendingSuspendGapMs = isLowCpuGap ? elapsedMs : 0;
+    if (
+      newMaxMs >= suspendThresholdMs &&
+      suspendGapMs >= suspendThresholdMs &&
+      newMaxMs <= suspendGapMs * 1.5
+    ) {
       histogram.reset();
       lastObservedMaxMs = 0;
       lastReportedMaxMs = 0;
+      pendingSuspendGapMs = 0;
       return;
     }

Note that simply dropping the elapsedMs condition is not a sufficient repair: on tick N+1 the denominator is only ~20 ms, so post-wake CPU very easily clears 1% of it. In my trace tick N+1's ratio came out at 0.0060 — it passed, but with almost no margin. The gap value has to be carried, not the conditions relaxed.

Why the 15 unit tests don't catch it

I applied the patch above to event-loop-lag.ts and re-ran event-loop-lag.test.ts: 15/15 pass, unchanged. The suite cannot tell the broken and fixed implementations apart, because every test assigns histogram.max = … before advanceTimersByTimeAsync, which hard-codes the one timer ordering that happens roughly 40% of the time in production. Worth adding a case that mutates histogram.max on the tick after the setSystemTime jump — it fails on head and passes with the carry.


Medium

1. ACP_START_TIMEOUT_MS (30 s) bites hardest in exactly the window it's meant to cover. A fresh qwen --acp boot right after a host wake competes with every other process resuming. If initialize misses 30 s, bridge.start() rejects, and start.ts:272 treats any rejection anywhere in the loop as terminal — straight to process.exit(1), bypassing the MAX_CRASH_RESTARTS budget entirely. (Not a regression; the old handler did the same. But the new timeout makes the path much easier to reach.) Consider letting a start failure count as a crash and re-enter the loop rather than hard-exiting on the first one.

2. Replacement-bridge coalescing only covers the post-start() window. If the replacement dies during await bridge.start() (line 256), the listener does set recoveryRequested = true (line 217) — but start() rejects, the .catch runs, and the while at line 270 is never evaluated. The PR description's "including disconnects from a replacement bridge while recovery is in progress" holds only when start() resolves; the keeps the readiness gate blocked and coalesces replacement disconnects test exercises only that path.

3. The other side of the CPU-ratio heuristic. A child genuinely wedged on a blocking syscall — hung sync I/O, deadlocked native addon, contended lock — burns ~0% CPU and is indistinguishable from suspension. Measured: in-process Atomics.wait for 2000 ms at 0% CPU is suppressed on both head and the patched build. Since #6329 is specifically about wedged-but-alive children, it's worth stating explicitly that this is accepted. A wall-vs-monotonic delta (Date.now() vs performance.now()) would separate the two on Linux/macOS, where CLOCK_MONOTONIC is frozen across an S3 suspend but keeps running through a blocking syscall — I could not verify that here, since SIGSTOP does not freeze CLOCK_MONOTONIC.


Minor

  • The stated invariant is broader than the code. ChannelBase.ts:353-356 says "every path that resolves a session or calls the bridge must await waitForBridgeRecovery()". cancelSession (~2130, ~2143, ~4541, ~5258) and respondToPermission (~506, ~547, ~727, ~2798) are not gated. They're benign — a crash already resolves pending permissions as cancelled, and there's nothing left to cancel — so I'd narrow the comment to session-resolution and prompt-capture paths rather than add gates.
  • waitForBridgeRecovery's completedRecovery guard is unreachable with this gate: release() clears pending before resolving, so a waiter that resumes always reads undefined on the next loop. Harmless, but it reads as if it's handling a case that can't occur. (Message ordering is fine, by the way — waiters resume in arrival order and all drain before any new block() can land.)
  • Three copies of the 5-minute constant: ACP_EVENT_LOOP_STALL_RESTART_MS, DEFAULT_EVENT_LOOP_SUSPEND_THRESHOLD_MS, and the local ACP_EVENT_LOOP_SUSPEND_THRESHOLD_MS in acpAgent.ts. The test already imports the first from @qwen-code/channel-base to assert they match, so the dependency exists — importing it in the source too would make the invariant structural instead of test-enforced.
  • startEventLoopLagMonitor now always creates the interval, not just when onNewMaxStall is set. Both in-repo consumers pass the callback so nothing regresses today, but it's exported from packages/core, and a snapshot-only caller now pays a 20 ms timer plus a process.cpuUsage() syscall per tick and gets its histogram silently reset on suspension. Worth a JSDoc line.

What's good

  • Collapsing the two duplicated disconnect handlers into createBridgeRecovery() is a real win — that duplication was a standing hazard, and −170/+200 for strictly more behaviour is a good trade.
  • Not reconnecting the adapters is the right call, and it checks out: the adapters carry their own reconnect (WeComAdapter.startKickReconnect, DingtalkConnectionManager), so nothing is left unrepaired. start.ts is also the only AcpBridge owner in the tree, so the fix has full coverage of that surface.
  • The recoveryEpoch design is subtle and correct: re-reading it after the store.update await, so a bump during the write doesn't swallow a genuine failure. The records a genuine failure when recovery starts before the prompt runs test pins exactly that, which is the case that would have been easy to get wrong.
  • The loop timeout is applied after the gate wait, so a slow recovery can't eat a scheduled loop's budget.
  • cleanupStartedChannels now also bridge.stop()s — the old startSingle give-up path left the child alive.
  • crashTimestamps is pruned now instead of growing for the process lifetime.
  • Asserting absence via await expect(vi.waitFor(...)).rejects.toThrow() is a genuinely good pattern for "this must not happen within N ms", and the inline comments explaining why the window is long enough are the right thing to leave behind.

Security

Nothing concerning. No new network, filesystem, or credential surface; the readiness gate only delays existing work, and sanitizeLogText still fronts the stderr path that feeds stall detection.


Verdict: hold for the blocker. Parts 2 and 3 are ready; part 1 needs the carry (or an equivalent) plus a test that varies the tick on which histogram.max moves, otherwise the wake scenario in the title is still a coin flip.


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

wenshao pushed a commit that referenced this pull request Jul 31, 2026
The primary attempt took run-agent.mjs's 50-minute default while its step
capped at 80, so a third of that step was unreachable and every "ran out
of time" round ended for a reason this file never named. Raising it
surfaced the larger problem: the budget was never checked as a whole.

Measured on one scan (run 30646547838): setup 5-7m in earlier steps, so
it never competes with the agent step; Triage and address 50m03s on #8005
round 9 (its own timer) and 12m45s on #8211; the Verification gate 22m48s
on #8211 — the largest consumer in the job, and unbounded; push, report
and finalize 3-4s. The old arithmetic ("80 + 20 leaves ~50 for setup, two
verification passes, and reporting") predates that measurement: the real
worst case was 7 + 80 + 23 + 20 + 23 = 153 against a 150-minute job, and
a JOB timeout cancels the always() reporters, which is the silent round
the design exists to prevent.

Sized for the PRs that actually exhaust the budget (47 and 35 changed
files): agent 120m under a 130m step cap, each verification gate bounded
at 60m — 2.6x the measured pass, and a graceful degrade because both
gates already carry continue-on-error, so a bound turns a job kill into
the ordinary verification-failure path that reports. Worst case
7 + 130 + 60 + 20 + 60 + 3 = 280 against a 300-minute job, itself under
the 360-minute ceiling ubuntu-latest imposes regardless.

Raising the budget does not make a doomed round cheaper — each exhausted
budget still pushes nothing, which is why TIMEOUT_WINDOW_CAP stops a PR
after three in one window. It buys rounds that were close to finishing.

The test now asserts the SUM against the job cap and the count of bounded
steps, because asserting the numbers individually is exactly what let
153-against-150 pass. Mutations checked: budget over its step cap, either
verification bound removed, job cap back to 150, job cap over the runner
ceiling, and continue-on-error dropped are each caught.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed. Suggestions are inline. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

— qwen3.8-max-preview via Qwen Code /review

Comment on lines 85 to 87
const mockChannelLoopSchedulerStop = vi.hoisted(() => vi.fn());
const mockChannelLoopSchedulerMarkRecovery = vi.hoisted(() => vi.fn());
const mockChannelLoopScheduler = vi.hoisted(() =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] mockChannelLoopSchedulerMarkRecovery is declared here and wired into the scheduler mock below (line 91), but it is never asserted in any recovery test. This leaves the scheduler?.markBridgeRecovery() call in createBridgeRecovery (start.ts:226) unverified at the integration level. — Concrete cost: if a future refactor removes that call, no test fails; recovery-aborted in-flight loop prompts would again be recorded as agent failures (incrementing consecutiveFailures, auto-disabling loops at maxConsecutiveFailures) — the exact bug the recovery epoch was added to prevent. The unit tests in ChannelLoopScheduler.test.ts verify markBridgeRecovery() in isolation, but nothing verifies start.ts invokes it during recovery.

Add an assertion to at least one recovery test (e.g. 'recovers a standalone bridge without reconnecting the channel adapter'), alongside the existing expect(mockChannelLoopSchedulerStop).not.toHaveBeenCalled():

expect(mockChannelLoopSchedulerMarkRecovery).toHaveBeenCalled();

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 3/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 3/100 轮)。改动内容与我反驳保留之处如下:

Review feedback addressed

Blocker: two-timer race in suspension classification (round 2 #1, round 1 Blocker)

Decision: Fixed.

checkHistogram() required both elapsedMs >= suspendThresholdMs and newMaxMs >= suspendThresholdMs on the same tick, but those come from two independent libuv timers whose firing order is not fixed. After a host wake, the gap lands in the histogram one tick late ~50–80% of the time, so the wake gap was reported as a stall and the healthy child was SIGKILLed — the exact outcome the PR exists to prevent.

Change: the low-CPU gap is now carried forward one tick via pendingSuspendGapMs. When elapsedMs >= suspendThresholdMs with low CPU, the gap value is latched; on the next tick the carried value qualifies the late histogram max. A newMaxMs <= suspendGapMs * 1.5 sanity check prevents suppressing a real stall that is much larger than the carried gap. Added a regression test that raises histogram.max on the tick after the clock jump — it fails on the old code and passes with the carry.

Files: event-loop-lag.ts, event-loop-lag.test.ts

Vacuous gate test (round 2 #3)

Decision: Fixed.

rechecks bridge recovery after inbound preprocessing has started used vi.waitFor on a negative assertion (succeeds on first poll, synchronises nothing) and await Promise.resolve() (one microtask, not enough for the ungated flow to reach bridge.prompt). Both assertions held whether or not the gate existed.

Change: replaced with the await expect(vi.waitFor(() => expect(bridge.prompt).toHaveBeenCalled(), { timeout: 500, interval: 25 })).rejects.toThrow() pattern used by the other eight recovery-gate tests.

Files: ChannelBase.test.ts

Assert markBridgeRecovery in start.test.ts (inline rc:3692617995)

Decision: Fixed.

mockChannelLoopSchedulerMarkRecovery was wired into the scheduler mock but never asserted, leaving the scheduler?.markBridgeRecovery() call in createBridgeRecovery unverified at the integration level.

Change: added expect(mockChannelLoopSchedulerMarkRecovery).toHaveBeenCalled() to the recovers a standalone bridge without reconnecting the channel adapter test.

Files: start.test.ts

Recovery-aborted failures recorded nowhere (round 2 #4)

Decision: Fixed.

A genuine failure (model error, prompt timeout) that coincided with a recovery left no lastStatus, lastError, or stderr trace — a debuggability regression in the exact scenario #6329 was filed about.

Change: the recovery-epoch catch path now persists lastStatus: 'error', lastError, and lastFinishedAt while still skipping the consecutiveFailures increment. Updated the existing test to verify the error is recorded without a failure-count write.

Files: ChannelLoopScheduler.ts, ChannelLoopScheduler.test.ts

Three copies of the 5-minute threshold (round 2 #6, round 1 Minor)

Decision: Fixed.

acpAgent.ts duplicated the 5-minute literal with a comment saying "match the parent channel bridge's restart threshold", while the test imported the real constant from @qwen-code/channel-base to assert they match.

Change: acpAgent.ts now imports ACP_EVENT_LOOP_STALL_RESTART_MS from @qwen-code/channel-base (the dependency already existed in package.json). The local constant and its comment are removed. The unused DEFAULT_EVENT_LOOP_SUSPEND_THRESHOLD_MS re-export from core/telemetry/index.ts (zero importers) is also removed; the constant itself stays in event-loop-lag.ts as the default value.

Files: acpAgent.ts, core/telemetry/index.ts

Invariant comment overstates the code (round 2 #8, round 1 Minor)

Decision: Fixed.

The comment said "every path that resolves a session or calls the bridge" but respondToPermission, cancelSession, and availableCommands are not gated (benignly — they're downstream of the entry gate or respond to events from the current bridge).

Change: narrowed to "session-resolution and prompt-capture paths".

Files: ChannelBase.ts


Declined

Finding Reason
Round 2 #2 — CPU ratio blind spot / liveness probe Significant design addition (ACP round-trip liveness check after a filtered gap). The reviewer explicitly says it "could reasonably be a follow-up." Should be tracked as a separate issue.
Round 2 #5registerChannelLoopMcpServer timeout The reviewer confirms "it can't hang today" because channelLoopMcpServer is null until registerChannelLoopToolHandler runs, which is after start(). Defense for an impossible case per Simplicity First.
Round 2 #7 — daemon telemetry reset on wake Observation about a behaviour change the daemon opted into by omission. The reviewer says "probably desirable." Not worth the diff growth; can be documented in the PR body if the maintainer wants.
Round 1 Medium #1ACP_START_TIMEOUT_MS terminal exit "Not a regression" per the reviewer — the old handler did the same. Pre-existing design issue, out of scope.
Round 1 Medium #2 — coalescing during start() Edge case where the replacement dies during await bridge.start(). Not flagged as required; the .catchprocess.exit(1) path is pre-existing.
Round 1 Minor — completedRecovery guard unreachable Harmless loop-termination guard. Removing it saves nothing and the defensive check protects against a future caller that doesn't clear the promise.
Round 1 Minor — interval always created / JSDoc Both in-repo consumers pass onNewMaxStall. No live regression. Not worth the diff.

Verification

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • vitest packages/core event-loop-lag.test.ts — 16 passed (15 existing + 1 new regression test)
  • vitest packages/channels/base ChannelLoopScheduler.test.ts — 25 passed
  • vitest packages/channels/base AcpBridge.test.ts — 36 passed
  • vitest packages/channels/base ChannelBase.test.ts — 559 passed
  • vitest packages/cli commands/channel/start.test.ts — 31 passed
  • vitest packages/cli acp-integration/acpAgent.test.ts — 353 passed
中文说明

已处理的审查反馈

阻塞项:挂起分类中的双定时器竞态(第二轮 #1,第一轮阻塞项)

决定: 已修复。

checkHistogram() 要求在同一个 tick 上同时满足 elapsedMs >= suspendThresholdMsnewMaxMs >= suspendThresholdMs,但这两个值来自两个独立的 libuv 定时器,其触发顺序不固定。主机唤醒后,间隙在约 50–80% 的情况下晚一个 tick 才进入直方图,导致唤醒间隙被报告为卡顿,健康子进程被 SIGKILL——这正是本 PR 要防止的结果。

修改: 通过 pendingSuspendGapMs 将低 CPU 间隙向前携带一个 tick。当 elapsedMs >= suspendThresholdMs 且 CPU 使用率低时,锁定间隙值;下一个 tick 用携带的值来匹配迟到的直方图最大值。newMaxMs <= suspendGapMs * 1.5 的合理性检查防止抑制远大于携带间隙的真实卡顿。新增了一个回归测试,在时钟跳变之后的 tick 上提升 histogram.max——在旧代码上失败,在修复后通过。

文件:event-loop-lag.tsevent-loop-lag.test.ts

空门测试(第二轮 #3

决定: 已修复。

rechecks bridge recovery after inbound preprocessing has started 使用了否定断言的 vi.waitFor(首次轮询即成功,不起同步作用)和 await Promise.resolve()(单个微任务,不足以让无门控流程到达 bridge.prompt)。无论门控是否存在,两个断言都通过。

修改: 替换为其他八个恢复门控测试使用的 await expect(vi.waitFor(...)).rejects.toThrow() 模式。

文件:ChannelBase.test.ts

在 start.test.ts 中断言 markBridgeRecovery(行内评论 rc:3692617995)

决定: 已修复。

mockChannelLoopSchedulerMarkRecovery 已连接到调度器 mock 但从未被断言,导致 createBridgeRecovery 中的 scheduler?.markBridgeRecovery() 调用在集成层面未被验证。

修改:recovers a standalone bridge without reconnecting the channel adapter 测试中添加了 expect(mockChannelLoopSchedulerMarkRecovery).toHaveBeenCalled()

文件:start.test.ts

恢复期间中止的故障无处记录(第二轮 #4

决定: 已修复。

与恢复同时发生的真实故障(模型错误、提示超时)不会留下 lastStatuslastError 或 stderr 痕迹——这正是 #6329 所针对场景中的可调试性回退。

修改: 恢复纪元的 catch 路径现在会持久化 lastStatus: 'error'lastErrorlastFinishedAt,同时仍跳过 consecutiveFailures 递增。更新了现有测试以验证错误被记录但不写入失败计数。

文件:ChannelLoopScheduler.tsChannelLoopScheduler.test.ts

5 分钟阈值的三份拷贝(第二轮 #6,第一轮次要项)

决定: 已修复。

acpAgent.ts 重复了 5 分钟字面量并注释"匹配父通道桥的重启阈值",而测试从 @qwen-code/channel-base 导入真实常量来断言它们匹配。

修改: acpAgent.ts 现在从 @qwen-code/channel-base 导入 ACP_EVENT_LOOP_STALL_RESTART_MS(依赖已存在于 package.json)。移除了本地常量及其注释。同时移除了 core/telemetry/index.ts 中无消费者的 DEFAULT_EVENT_LOOP_SUSPEND_THRESHOLD_MS 再导出;常量本身保留在 event-loop-lag.ts 中作为默认值。

文件:acpAgent.tscore/telemetry/index.ts

不变量注释夸大了代码(第二轮 #8,第一轮次要项)

决定: 已修复。

注释说"每个解析会话或调用桥的路径",但 respondToPermissioncancelSessionavailableCommands 没有门控(它们是良性的——位于入口门控下游或响应来自当前桥的事件)。

修改: 缩小为"会话解析和提示捕获路径"。

文件:ChannelBase.ts


已拒绝

发现 原因
第二轮 #2 — CPU 比率盲点 / 存活性探测 重大设计添加(过滤间隙后的 ACP 往返存活性检查)。审查者明确表示"可以作为后续工作"。应作为单独 issue 跟踪。
第二轮 #5registerChannelLoopMcpServer 超时 审查者确认"目前不会挂起",因为 channelLoopMcpServerregisterChannelLoopToolHandler 运行前为 null,而后者在 start() 之后。按简洁优先原则,这是对不可能情况的防御。
第二轮 #7 — 守护进程遥测在唤醒时重置 关于守护进程因省略而选择的行为变更的观察。审查者说"可能是期望的"。不值得增加 diff;如维护者需要可在 PR 正文中说明。
第一轮中等 #1ACP_START_TIMEOUT_MS 终端退出 审查者说"不是回退"——旧处理器也是如此。预先存在的设计问题,超出范围。
第一轮中等 #2start() 期间的合并 替换桥在 await bridge.start() 期间死亡的边缘情况。未被标记为必需;.catchprocess.exit(1) 路径是预先存在的。
第一轮次要 — completedRecovery 守卫不可达 无害的循环终止守卫。移除它不会节省任何东西,防御性检查保护了未来不清除 promise 的调用者。
第一轮次要 — 间隔始终创建 / JSDoc 两个仓库内消费者都传递了 onNewMaxStall。无实际回退。不值得增加 diff。

验证

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • vitest packages/core event-loop-lag.test.ts — 16 通过(15 个现有 + 1 个新回归测试)
  • vitest packages/channels/base ChannelLoopScheduler.test.ts — 25 通过
  • vitest packages/channels/base AcpBridge.test.ts — 36 通过
  • vitest packages/channels/base ChannelBase.test.ts — 559 通过
  • vitest packages/cli commands/channel/start.test.ts — 31 通过
  • vitest packages/cli acp-integration/acpAgent.test.ts — 353 通过

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

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

No issues found. LGTM! ✅

— qwen3.8-max-preview via Qwen Code /review

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Local verification — real channel runtime, before vs after

I built both revisions locally and ran the same failure scenarios against a real qwen channel start process, so this is runtime evidence rather than a code read. CI is already green here; this covers what CI does not exercise.

PR head e70d198d5
Baseline 01afcb0bb (merge-base with main)
Host macOS arm64 (Darwin 25.6), Node v24.18.1
Runtime full npm run build && npm run bundle, then the bundled dist/cli.js channel start
Adapter the repo's WebSocket plugin-example channel against a mock platform server that logs every adapter connect/close; a proactive-capable variant of it for the scheduled-loop case
Agent real ACP child process, real model backend, isolated QWEN_HOME
# Scenario Baseline 01afcb0bb With this PR
1 Host suspend: SIGSTOP channel + ACP child for 330 s, then SIGCONT 330 s gap reported as a stall, healthy child killed, bridge restarted, adapter reconnected no stall reported, same child alive, adapter untouched, answers on the first message after wake
2 SIGKILL the ACP child, inbound message 0.5 s later (inside the 3 s restart delay) message dropped (90 s timeout), adapter connection replaced, channel stopped replying message held by the readiness gate and answered after the replacement bridge came up
3 Active stall: event loop blocked 310 s at 82 % CPU reported reported (310311 ms → still kill-eligible)
4 Scheduled loop in flight when the bridge dies loop kept, run outcome dropped, scheduler stopped and restarted loop kept, run finalized, scheduler kept running

1. Host suspend — the failure this PR is about

SIGSTOP on the channel process and its ACP child for 330 s, then SIGCONT. The processes are genuinely frozen: wall clock advances, no CPU is consumed — the same shape as a laptop sleep.

host suspend before/after

Baseline emitted [perf] acp agent event loop stall: max=330175.610879ms and killed a child that had never been wedged (pid 7377186452). With this PR the 20 s of post-wake log contains no stall line, no kill and no restart, the ACP pid is unchanged, and the next message was answered in 2.7 s.

2. ACP bridge death with traffic in the recovery window

SIGKILL the ACP child, then post an inbound message 0.5 s later — inside the 3 s restart delay.

bridge recovery before/after

With this PR the message was held and answered ("42", 9.9 s after it was sent, 2.9 s after Bridge restarted), the follow-up came back in 2.3 s, and the adapter socket was never touched — connectCount stayed 1, closeCount 0 across the whole recovery. On the baseline the in-window message timed out at 90 s, and so did the next one.

3. An active stall must still be killable

310 s of blocked-but-burning event loop against the built monitor, with the ACP agent's own options (suspendThresholdMs = ACP_EVENT_LOOP_STALL_RESTART_MS):

blocked 310000ms at cpuRatio=0.821
[perf] acp agent event loop stall: max=310311.387135ms   → kill-eligible (>= 300000)

The suspension filter does not swallow the case it must not swallow. A 2 s stall on a warm loop is still reported (2001.7 ms here vs 2013.3 ms on the baseline), so ordinary stall telemetry is unchanged.

4. Scheduled loop in flight when the bridge dies

A cron loop was 1.5 s into its prompt when the ACP child was killed.

stall and loop

Neither revision auto-disables the loop for this abort shape — the prompt resolves empty rather than throwing, so the new recoveryEpoch branch is not what saves it here (that path is covered by the unit tests). What is visible at runtime: this PR keeps the scheduler running through the recovery and finalizes the aborted run, where the baseline stops and restarts the scheduler and never records the run at all.

5. Focused tests and static checks

tests and static checks

Notes for the merge decision

  1. Trade-off worth naming. A genuine ≥ 5-minute block that burns no CPU — an ACP child stuck on a hung syscall, or stopped by a debugger — is now classified as suspension, so the child is not killed. The histogram is reset, so a later real stall is still detected; recovery for that shape is deferred rather than lost. Given that a false kill after every laptop sleep is the common case, this looks like the right trade, but it is a behavior change beyond the sleep scenario.

  2. Independent bug this PR happens to route around. On the baseline, every message after a bridge recovery went unanswered — both in the crash run (message inside the window) and in the wake run (no message in the window at all), each timing out at 90 s. The cause is in the reference adapter, not in start.ts: MockPluginChannel.connect() registers ws.on('close', () => { this.ws = null }), and during the baseline's disconnect() + connect() the old socket's close event lands after the new one is assigned, wiping the live socket — inbound still arrives, every outbound reply is silently dropped. It is pre-existing and out of scope here, but it is a concrete example of the adapter-owned state the PR's design section says a reconnect can lose. Worth a one-line follow-up in plugin-example (if (this.ws === ws) this.ws = null), and worth checking the same pattern in the real adapters.

  3. startEventLoopLagMonitor now arms its 20 ms interval unconditionally, where before it only ran when onNewMaxStall was passed. Both current callers pass it, so nothing changes today. The daemon's monitor also picks up the default suspension filter, so post-sleep daemon event loop stall detected warnings should stop as well — that reads as intended, just not scoped to channels.

Verdict: the headline failure reproduces on the baseline and is gone on this PR, the recovery path behaves as described, and I found no regression in the paths I exercised.

中文版本(点击展开)

本地真实环境验证 —— 改动前后对比

我在本地分别构建了 PR 版本与基线版本,用同一组故障场景跑真实的 qwen channel start 进程,所以下面是运行时证据而非读代码得出的结论。本 PR 的 CI 已全绿,这里补的是 CI 覆盖不到的行为。

PR 版本 e70d198d5
基线 01afcb0bb(与 main 的 merge-base)
环境 macOS arm64(Darwin 25.6)、Node v24.18.1
运行方式 完整 npm run build && npm run bundle 后运行打包产物 dist/cli.js channel start
适配器 仓库自带的 WebSocket plugin-example 通道,对接一个会记录每次连接/断开的模拟平台服务;定时循环场景使用了它的一个支持主动推送的变体
Agent 真实 ACP 子进程、真实模型后端、隔离的 QWEN_HOME
# 场景 基线 01afcb0bb 本 PR
1 主机休眠:对通道进程与 ACP 子进程 SIGSTOP 330 秒后 SIGCONT 330 秒间隔被当成 stall,健康的子进程被杀,桥重启,适配器连接被重建 未上报 stall,子进程存活如初,适配器连接未动,唤醒后第一条消息即正常回答
2 SIGKILL ACP 子进程,0.5 秒后发消息(落在 3 秒重启窗口内) 消息被丢弃(90 秒超时),平台连接被替换,此后通道不再回复 消息被就绪门挂起,替换桥就绪后正常回答
3 主动 stall:事件循环以 82% CPU 阻塞 310 秒 上报 上报(310311 ms,仍满足击杀阈值)
4 桥挂掉时正在执行的定时循环 循环保留,但本次运行结果未被记录,调度器被停后重启 循环保留,本次运行被正常收尾,调度器全程未停

场景 1:基线打印 [perf] acp agent event loop stall: max=330175.610879ms 并杀掉了一个从未卡死的子进程(pid 7377186452);本 PR 唤醒后 20 秒日志中没有任何 stall/击杀/重启记录,ACP pid 不变,下一条消息 2.7 秒回答。

场景 2:本 PR 中窗口内的消息被挂起并在替换桥就绪后回答("42",发出后 9.9 秒),随后一条 2.3 秒回答,整个恢复过程适配器连接数保持 1、断开数 0;基线中窗口内消息 90 秒超时,之后一条同样超时。

场景 3:以 ACP agent 相同参数(suspendThresholdMs = ACP_EVENT_LOOP_STALL_RESTART_MS)跑构建产物,阻塞 310 秒、CPU 占比 0.821,仍上报 max=310311.387135ms,满足 ≥ 300000 的击杀条件;2 秒短 stall 上报值(2001.7 ms)与基线(2013.3 ms)一致,常规 stall 遥测未受影响。

场景 4:两个版本都不会因这次中止而自动禁用循环——该场景下 prompt 是"返回空结果"而非抛错,所以真正生效的不是新增的 recoveryEpoch 分支(那条路径由单测覆盖)。运行时可见的差异是:本 PR 在恢复期间保持调度器运行并把这次运行收尾,基线则停掉调度器且完全没有记录这次运行。

场景 5:覆盖改动代码的 7 个测试文件共 1117 条用例全部通过;prettier --checkeslint --max-warnings 0、三个包的 tsc --noEmit、完整构建均为 0 退出。

合并前值得注意的几点

  1. 需要明确的取舍:真正阻塞 ≥ 5 分钟但不消耗 CPU 的情况(例如 ACP 子进程卡在挂起的系统调用上、或被调试器 STOP),现在会被判定为主机休眠而不再击杀子进程。由于直方图被重置,后续真实 stall 仍会被发现,因此只是推迟而非丢失恢复机会。相对于"每次笔记本睡眠后都误杀",这个取舍是合理的,但它确实是超出休眠场景的行为变化。

  2. 顺带暴露的一个独立缺陷:基线下桥恢复之后的消息都得不到回复:崩溃场景(窗口内有消息)与休眠场景(窗口内没有任何消息)两次运行均 90 秒超时。根因不在 start.ts,而在参考适配器:MockPluginChannel.connect() 注册了 ws.on('close', () => { this.ws = null }),基线恢复流程的 disconnect() + connect() 会让旧连接的 close 事件在新连接赋值之后触发,把仍然存活的 socket 置空——入站仍能收到,出站回复被静默丢弃。它是既有问题、不属于本 PR 范围,但正好印证了 PR 设计说明中"重连适配器会丢失适配器自有状态"的判断。建议后续在 plugin-example 里补一行 if (this.ws === ws) this.ws = null,并检查真实适配器是否有同样写法。

  3. startEventLoopLagMonitor 现在无条件启动 20 ms 定时器(此前仅在传入 onNewMaxStall 时启动)。当前两个调用方都会传,因此今天没有行为变化。守护进程的监控器也会继承默认的休眠过滤,睡眠后的 daemon event loop stall detected 警告应当一并消失——看起来是有意为之,只是影响范围不限于 channel。

结论:目标缺陷在基线上可稳定复现,在本 PR 上消失;恢复路径的行为与描述一致;在我覆盖到的路径上没有发现回归。

@yiliang114
yiliang114 added this pull request to the merge queue Aug 1, 2026
Merged via the queue into QwenLM:main with commit ddd672c Aug 1, 2026
46 checks passed
OrbitZore pushed a commit to OrbitZore/qwen-code that referenced this pull request Aug 1, 2026
…oom (QwenLM#8257)

* fix(autofix): budget the whole round, not just the agent step

The primary attempt took run-agent.mjs's 50-minute default while its step
capped at 80, so a third of that step was unreachable and every "ran out
of time" round ended for a reason this file never named. Raising it
surfaced the larger problem: the budget was never checked as a whole.

Measured on one scan (run 30646547838): setup 5-7m in earlier steps, so
it never competes with the agent step; Triage and address 50m03s on QwenLM#8005
round 9 (its own timer) and 12m45s on QwenLM#8211; the Verification gate 22m48s
on QwenLM#8211 — the largest consumer in the job, and unbounded; push, report
and finalize 3-4s. The old arithmetic ("80 + 20 leaves ~50 for setup, two
verification passes, and reporting") predates that measurement: the real
worst case was 7 + 80 + 23 + 20 + 23 = 153 against a 150-minute job, and
a JOB timeout cancels the always() reporters, which is the silent round
the design exists to prevent.

Sized for the PRs that actually exhaust the budget (47 and 35 changed
files): agent 120m under a 130m step cap, each verification gate bounded
at 60m — 2.6x the measured pass, and a graceful degrade because both
gates already carry continue-on-error, so a bound turns a job kill into
the ordinary verification-failure path that reports. Worst case
7 + 130 + 60 + 20 + 60 + 3 = 280 against a 300-minute job, itself under
the 360-minute ceiling ubuntu-latest imposes regardless.

Raising the budget does not make a doomed round cheaper — each exhausted
budget still pushes nothing, which is why TIMEOUT_WINDOW_CAP stops a PR
after three in one window. It buys rounds that were close to finishing.

The test now asserts the SUM against the job cap and the count of bounded
steps, because asserting the numbers individually is exactly what let
153-against-150 pass. Mutations checked: budget over its step cap, either
verification bound removed, job cap back to 150, job cap over the runner
ceiling, and continue-on-error dropped are each caught.

* fix(autofix): bound every long step and cap the timeout override (QwenLM#8257)

* fix(autofix): enforce the timeout ceiling and trim review feedback (QwenLM#8257)

* fix(autofix): force base-10 clamp and align stale bound with job cap (QwenLM#8257)

* fix(autofix): close the clamp int64 escape and pin it with a bash replay (QwenLM#8257)

* fix(autofix): give the timeout clamp a floor, not only a ceiling

The review's Finding 1: the guard clamped only the ceiling, and the
uncovered side is the likelier typo. Every comment in this file, the PR
body and the operator message speak in MINUTES; this one variable wants
MILLISECONDS. A maintainer told to "raise the agent time budget" who sets
QWEN_AUTOFIX_TIMEOUT_MS=120 armed a 120 ms timer — every round SIGTERMs
instantly, writes agent-timeout, and reports "ran out of time (timeout
(120ms))" until TIMEOUT_WINDOW_CAP trips and AutoFix stops on the PR,
advising the human to raise the budget they just raised. No warning
anywhere in that loop, which is the exact misreport the clamp exists to
prevent, reached from the other direction.

A 60000 ms floor rejects every minutes-shaped value, and it also closes
the `0`/`000` hole the review noted alongside it — those passed the bare
regex while the message asserted the value had to be positive. The
message now names the units, because a units confusion is the whole
failure mode.

Replayed the review's own table against the extracted block, stdout and
stderr separated: 7200000, 3600000 and the floor itself pass untouched;
120, 60, 0, 000 and 59999 all clamp with a warning, alongside the
over-cap, malformed, octal and int64 cases the previous round closed.
The test pins both boundaries from each side (59999 clamps, 60001 does
not) and asserts the warning names MILLISECONDS.

---------

Co-authored-by: verify <verify@local>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen Autofix <qwen-autofix@users.noreply.github.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.3.

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

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants