fix(daemon): drain recycled runtime generations - #11273
Conversation
|
Thanks — this is layer 4 of #8586, and it lines up with what that tracker asks for. Template looks good ✓ (every required section is there plus the Chinese half; only nit is the heading reads "Why it is needed" where the template says "Why it's needed" — not worth a re-push). Problem: planned rather than observed. #8586 is open on the background-automation roadmap and prescribes this layer almost word for word — explicit active/draining/dying generations, Session ownership preserved, no third live generation, Direction: aligned. Capping OS-live generations at two and failing closed on admission is the right shape for the layer-5 escalation that follows, and it's what the tracker's acceptance criteria demand ("never creates more than two live generations"). Size: cross-package ( Approach: focused, and I like that it resists adding public timeout config or a persistence migration, which the tracker explicitly rules out. Two observations rather than objections. The title says Risk: no high-risk-path matches. The real risk is the CI hole — because the base is Moving on to code review. 🔍 中文说明感谢贡献——这是 #8586 的第 4 层,和 tracker 的要求基本一致。 模板完整 ✓(所有必需章节加中文部分都在;唯一的小问题是标题写成 "Why it is needed",模板是 "Why it's needed"——不值得为此重推一次)。 问题:属于"计划中"而非"已观测"。#8586 仍是 open 状态,在 background-automation roadmap 上,几乎逐字规定了这一层的内容——显式的 active/draining/dying generation、保留 Session 归属、不允许第三个 live generation、容量不可用时返回 方向:一致。把 OS 层存活的 generation 上限设为 2、并在准入时 fail closed,对紧随其后的第 5 层升级来说是正确的形态,也正是 tracker 验收标准要求的("never creates more than two live generations")。 规模:跨包改动( 方案:聚焦,也很克制地没有引入公开超时配置或持久化迁移(tracker 明确排除这两项)。两点观察而非反对意见:标题写 风险:没有命中高风险路径。真正的风险是 CI 空洞——因为 base 是 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Read against What I'd want answered before merge: It went from They diverge in a state this PR makes reachable: generation A draining with sessions still attached, generation B dying (transport failure or a kill of the replacement). In that state The consumer that turns this into a wrong answer is preheat. So: is "live" meant to be "a non-dying generation exists" or "a generation can admit fresh work"? Both readings are defensible, and the design doc argues for the first one for liveness/active-work reporting. But the admission-side consumers need the second. Either split the two notions or narrow Two smaller things.
The What's good, specifically: replacing sequenceDiagram
participant P1 as Caller
participant P2 as AcpSessionBridge
participant P3 as Generation A draining
participant P4 as Generation B active
P1->>P2: requestRuntimeRecycle sessionId
P2->>P3: state draining, session owners kept
P2->>P4: ensureChannel spawns replacement
P1->>P2: new session work
P2->>P4: routed to the active generation
P3-->>P2: existing sessions still route here
P1->>P2: recycle again while A and B are both alive
P2-->>P1: 503 runtime_recycling, retryable
Test evidenceStating plainly what this section carries: the PR's own CI, read through the API — and there is almost none of it. I ran no build, test or typecheck myself (this is an unattended CI run, and triage never executes PR-derived code).
Not verified, and the reasons: the draining → replacement → admission sequence (no test in the diff — 0 test lines — and no CI lane that would run one); the 503 path and its Sandboxed verification would settle this: 中文说明基于 合并前我希望先有答案的问题: 它从 在本 PR 使其可达的状态下两者会分叉:generation A 处于 draining 且仍挂着 session,generation B 处于 dying(替代 generation 发生 transport failure 或被 kill)。此时 把这个矛盾变成错误答案的调用方是 preheat。 所以想确认:"live" 的含义是"存在一个非 dying 的 generation",还是"存在一个可以接收新工作的 generation"?两种解读都说得通,设计文档也主张在 liveness / active-work 上报上采用第一种。但准入侧的调用方需要第二种。要么把两个概念拆开,要么把 另外两个较小的问题。
明确说好的部分: 把 测试证据明确说明本节携带的内容:PR 自身的 CI,通过 API 读取——而且几乎没有。 我没有自己运行任何 build、test 或 typecheck(这是无人值守的 CI 运行,且 triage 从不执行 PR 派生的代码)。
未验证项及原因:draining → 替代 generation → 准入 这一整条链路(diff 中 0 行测试,也没有会运行测试的 CI 通道);503 路径及其在 REST 与 RPC 边界的 沙箱验证可以定论: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 3/5 — the structure is right and matches the tracker's spec, but I found one reachable inconsistency in the readiness signal and there is no runtime evidence of any kind behind it, so this needs your eyes rather than my approval. My independent proposal before comparing: explicit three-state generation, owner-pinned routing for existing Sessions, a hard cap on OS-live generations, retryable 503 on admission. That's what you built, so on structure we agree and I don't have a simpler path to offer — cutting 80% of this wouldn't solve the problem, since the cap and the drain are the point. Where I'd have written it differently is the two things in the review above: I'd have added a new predicate for "a non-dying generation exists" and left Did I verify the problem exists? Honestly — no, and neither did you. #8586 describes the failure mode in detail and prescribes this layer verbatim, which is stronger than a drive-by hardening PR, but it's a specification, not an observation. That's a legitimate way to build a roadmap stack and I'm not going to gate a maintainer's own tracked layer on a reproduction. I am noting that nothing here — not the PR, not CI, not a test — demonstrates the draining path actually working end to end. The part that decides the verdict is the evidence gap. Zero test lines on a lifecycle change whose whole value is behavioural, and Six months from now: the state machine will read fine and the design doc earns its place. The On volume: this is one of five stacked PRs (#11270 is still open beneath it). I judged this one on its own diff, and my hesitation is specific to Not approving, not requesting changes — deferring to you as the roadmap owner. Two calls are yours: whether "live" should mean admit-capable or merely non-dying (and if the former, the preheat/skill-toggle consumers need the narrower one), and whether The maintainer resolver found no area label on this PR and no prior human reviewer, so there's nobody to hand this to but you; I've not guessed a login or assigned it. 中文说明Confidence: 3/5——整体结构是对的,也符合 tracker 的规格,但我发现了一处在可达状态下的就绪信号不一致,而且背后完全没有任何运行时证据,因此这需要你亲自过目,而不是由我批准。 我在对比之前的独立方案是:显式的三态 generation、既有 Session 按 owner 固定路由、对 OS 层存活 generation 数设硬上限、准入时返回可重试的 503。你实现的正是这个,所以结构上我们一致,我也没有更简的路径可提——砍掉其中 80% 并不能解决问题,因为上限和 drain 本身就是重点。我会写得不一样的地方就是上面审查里的那两点:我会新增一个"存在非 dying generation"的判定函数,而让 我是否验证了问题确实存在?老实说——没有,你也没有。#8586 详细描述了故障模式并逐字规定了这一层,这比随手加固的 PR 强得多,但它是一份规格说明,不是一次观测。对于推进自己的 roadmap 分层,这是合理的做法,我不会因为缺少复现就拦下 maintainer 自己跟踪中的分层。我要指出的是:这里没有任何东西——PR 本身、CI、测试——证明 draining 路径端到端确实可用。 决定结论的是证据缺口。一个价值完全在行为上的生命周期改动,测试行数为 0;而 六个月之后:状态机本身读起来没问题,设计文档也有其价值。 关于数量:这是五个堆叠 PR 之一(#11270 仍在它下面未合并)。我是按本 PR 自身的 diff 判断的,我的犹豫针对的是 不批准,也不 request changes——转交给你这位 roadmap owner 决定。 两个判断由你来做:"live" 应该表示"可接收新工作"还是"仅非 dying"(如果是前者,preheat / 技能开关这些调用方需要更窄的那个);以及 maintainer 解析器在本 PR 上找不到 area 标签,也没有此前的人工 reviewer,所以除了你之外无人可交;我没有猜测任何 login,也没有指派。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
… into codex/issue-8586-runtime-generations
|
Addressed the reachable readiness inconsistency in The post-drain The updated #11270 base was merged into this branch. No tests were added and no local test, build, typecheck, or CI command was run. |
…ions' into codex/issue-8586-unresponsive-agent
… into codex/issue-8586-runtime-generations
…ions' into codex/issue-8586-unresponsive-agent
90244a6
into
codex/issue-8586-agent-watchdog
Local verification report — real daemon E2EI built a real verification environment for this PR (I'm a maintainer; this is a merge-readiness check, not a drive-by review). Everything below comes from a real To reach the three lifecycle transitions the Reviewer Test Plan asks for, I froze an ACP child with Bottom line: the design works exactly as described, but the PR is currently red and I'd hold merge on one blocker. ✅ What reproduces (the PR's own test plan, all three transitions)
Both wire mappings are correct: The base/head contrast in fig 1 is the real value of this PR: on base, the generation marked for retirement keeps absorbing fresh Sessions, so its retirement is deferred again for as long as new work arrives. On head it drains properly. That is a genuine fix. Also green on head: 🔴 Blocker —
|
state |
runtimeLive |
children | POST /session/<id>/prompt |
/health?deep channelAlive |
|
|---|---|---|---|---|---|
| base | active |
true |
1 | 202 |
true |
| head | stopping |
false |
1 | 202 |
true |
liveChannelInfo() now returns undefined for a draining generation, so runtimeLive flips false while isChannelLive() (which the PR widened) keeps /health saying channelAlive: true. The two status surfaces disagree about the same runtime.
That matters beyond cosmetics: scheduleSkillsReconciliation / scheduleMcpReconciliation both bail with 'deferred' when !snapshot.runtimeLive, so config reconciliation silently no-ops between a drain and the next generation actually becoming active. Live, I saw the capability state go {state: "stale", runtimeEpoch: 1} against runtimeEpoch: 2 after the recycle.
And the refusal window is not transient — it is bounded only by how long the two Sessions live:
I understand the fail-closed tradeoff is deliberate and I'm not asking you to change it. I am asking that the state be reported honestly while it holds, since runtimeLive: false on a runtime that is serving two Sessions is a signal controllers will act on.
🟡 Minor
- No
Retry-Afteron the new 503. It is the only 503 inerror-response.tsthat declaresretryable: truewithout one —init_timeoutandacp_channel_unavailableboth set it, andruntime_still_startingsets it without even claiming retryable. Confirmed on the wire (Retry-After present? false). Clients that honour the header will fall back to their default cadence. runtime_recyclingis undocumented. Every sibling code appears indocs/developers/qwen-serve-protocol.mdanddocs/users/qwen-serve.md; the natural home is thePOST /sessionerror list alongside the503 acp_channel_unavailablebullet. Right now the code exists only in the new design note.- No tests. The diff adds a three-state machine, a new error class, a new public interface method and two new wire paths, and touches zero test files.
grepfinds no test referencingBridgeRuntimeRecyclingErrororruntime_recycling. Given the blocker above was caught by an unrelated pre-existing test, the state machine itself is currently unguarded. requestRuntimeRecycleis dead code in this PR — declaration plus implementation, zero callers until fix(daemon): recycle unresponsive agent runtimes #11275. That is a reasonable way to stack, but it means the recycle entry point ships unexercised.- The
isDyingaccessor pair silently dropsfalse.set isDying(v) { if (v) this.state = 'dying'; }makesci.isDying = falsea no-op. All 10 current assignments are= true, so there is no live bug — but it is a trap for the next person, and worth a comment at minimum.
Verdict
Recommend changes requested: fix the aliveChannels cap so the suite goes green, then this is good to merge. The other items are worth folding in while you are in here, but none of them alone would block me.
How to reproduce
Harness at /root/git/h11273, arms at /root/git/pr11273 (head) and /root/git/pr11273-base (25df3993ab).
bash run-arm.sh base p10-status-ab.mjs # runtime status A/B
bash run-pr-full.sh # lifecycle + 503 refusal on head
bash run-arm.sh pr p4-drain-completes.mjs # drained generation is reaped, slot recovers
bash run-arm.sh pr p6-acp-ws.mjs # ACP /acp WebSocket error mapping
bash run-arm.sh pr p8-ensure.mjs # runtime/ensure mislabel + ERROR log
bash run-arm.sh pr p9-persistence.mjs # refusal window persistenceThe daemon runs with --initialize-timeout-ms 4000 purely to shorten the wait; nothing else is tuned. Freezing a child is kill -STOP / kill -CONT on the real qwen --acp pid.
中文版
本地验证报告 — 真实 daemon 端到端
我为这个 PR 搭建了真实的验证环境(我是维护者,这是合并前的检查)。下面所有结论都来自真实的 qwen serve daemon 和真实拉起的 qwen --acp 子进程,通过真实的 HTTP / WebSocket 驱动,没有 mock bridge。A/B 两侧分别是 PR head 6e6639d4f7 和它声明的 base codex/issue-8586-agent-watchdog @ 25df3993ab,各自独立 npm ci 并打包。
为了走到 Reviewer Test Plan 要求的三个生命周期转换,我用 SIGSTOP 冻结 ACP 子进程,让一次 workspace-control 调用超时。这是真实、未 mock 的 retireChannelAfterSessionsDrain 入口——和现有超时退役走的是同一条 BridgeTimeoutError 路径,也很贴近这个 stack 想解决的"卡住的 runtime"。
结论:设计完全按描述工作,但 PR 目前是红的,我建议先卡住这一个 blocker。
✅ 复现成功的部分(PR 自己的测试计划,三个转换全过)
| 声明 | 结果 |
|---|---|
| 新工作只使用 active generation | ✅ |
| recycle 后现有 Session 仍走原 generation,同时新 generation 成为 active | ✅ 老 Session 在子进程 A 上继续 202,子进程 B 服务新工作 |
旧 generation 未退出时第二次 recycle 不会创建第三个子进程,返回 runtime_recycling |
✅ 恰好 2 个子进程,第三次请求 503 |
| draining generation 在退出前仍受 channel liveness 和 active-work reporting 覆盖 | ✅ channelAlive: true;第二个 generation 出现后 active-work 覆盖率评级没有退化(两侧评级一致) |
| draining generation 最终确实被回收 | ✅ 最后一个 Session 关闭后约 250 ms 退出,准入立刻恢复,不会产生第三个子进程 |
两条线路的错误映射都正确(REST 503 + ACP JSON-RPC -32603 带 httpStatus: 503)。
fig 1 的 base/head 对比才是这个 PR 的真正价值:在 base 上,被标记退役的 generation 会继续吸收新 Session,只要新工作不断到来,它的退役就会被一再推迟;head 上它能正常 drain。这是实打实的修复。
head 上同样通过:npm run typecheck(exit 0)、npm run lint(exit 0)。packages/cli 的 src/serve 未受影响——15 个失败两侧完全一致(本地环境的既有噪声,与本 PR 无关)。
🔴 Blocker — packages/acp-bridge 在 head 上是红的,base 是绿的
base Test Files 35 passed (35) Tests 1959 passed (1959)
head Test Files 1 failed | 34 passed Tests 1 failed | 1958 passed
失败用例是 bridge.test.ts 里那条 "purges buffered guardrail events when restore fails...",抛出 BridgeRuntimeRecyclingError(src/bridge.ts:4503)。
我给 ensureChannel 加了埋点,打印拒绝时刻的 aliveChannels:
[{"state":"dying","sessions":0}, {"state":"dying","sessions":0}] channelInfo.state=dying
两个槽位里都是零工作的终态子进程。 它们永远不可能再接受 Session,所以拒绝准入毫无收益——而 base 会正常拉起替代者。这个场景并不刁钻:一次失败的 session/load 会拆掉自己的 channel,重试需要新的 channel,而这个上限把"尸体"也算进去了。>= 2 这个判断对 draining generation 是对的,对 dying generation 是错的。
候选修复(我已应用、重新构建并全量复跑):
- if (aliveChannels.size >= 2) throw new BridgeRuntimeRecyclingError();
+ if (
+ aliveChannels.size >= 2 &&
+ Array.from(aliveChannels).some((c) => c.state !== 'dying')
+ ) {
+ throw new BridgeRuntimeRecyclingError();
+ }结果:1959/1959 全绿,并且 fig 1、fig 2 里的所有行为在真实 daemon 上原样复现——上限仍是 2,第三次 POST /session 仍然 503 runtime_recycling,现有 Session 仍然固定在原 generation。方案可以换,关键是这个上限不应该把已经终态的 generation 算进去。
🟠 重要 — POST /workspace/runtime/ensure 上这个拒绝被贴错标签、日志级别也过高
ensureChannel 也会经由 preheat() 到达,而 WorkspaceRuntimeCoordinator.ensure() 会把除 WorkspaceRuntimeStillStartingError 之外的所有错误包成 WorkspaceRuntimeInitializationError。于是同一个状态报出两种结果:
POST /session -> 503 {"code":"runtime_recycling","errorKind":"runtime_recycling","retryable":true}
POST /workspace/runtime/ensure -> 503 {"code":"runtime_initialization_failed"} <- 没有 errorKind,没有 retryable
在 daemon 日志里,同一个预期内的状态在一条路由上是带完整堆栈的 ERROR,在另一条路由上是干净的 WARN——因为 WorkspaceRuntimeInitializationError 走 reportBridgeError,而 BridgeRuntimeRecyclingError 走 recordExpectedBridgeError。
建议像现在放行 WorkspaceRuntimeStillStartingError 那样,让 BridgeRuntimeRecyclingError 直接穿过 ensure()。
🟠 重要 — 一次普通超时之后 /workspace/runtime/status 就说 runtime 正在 stopping
这是最常见的路径,不是双 generation 的角落场景。一次 workspace-control 超时,Session 仍然打开且完全可用:
state |
runtimeLive |
子进程数 | POST /session/<id>/prompt |
/health?deep channelAlive |
|
|---|---|---|---|---|---|
| base | active |
true |
1 | 202 |
true |
| head | stopping |
false |
1 | 202 |
true |
liveChannelInfo() 现在对 draining generation 返回 undefined,所以 runtimeLive 翻成 false;而被本 PR 放宽的 isChannelLive() 让 /health 继续报 channelAlive: true。两个状态面对同一个 runtime 给出了互相矛盾的答案。
这不只是观感问题:scheduleSkillsReconciliation / scheduleMcpReconciliation 在 !snapshot.runtimeLive 时都会直接 'deferred',所以从 drain 到新 generation 真正 active 之间,配置协调会静默变成 no-op。实测中我看到 recycle 之后能力状态变成 {state: "stale", runtimeEpoch: 1},而 runtimeEpoch 已经是 2。
而且这个拒绝窗口不是瞬时的——它的长度只取决于那两个 Session 活多久(实测 70 秒内持续 503,关掉其中一个 Session 后立刻恢复 200)。
我理解 fail-closed 是刻意取舍,不要求你改这个策略。我要求的是:这个状态持续期间要如实上报——一个正在服务两个 Session 的 runtime 报 runtimeLive: false,控制面是会据此动作的。
🟡 次要
- 新的 503 没有
Retry-After。 它是error-response.ts里唯一一个声明retryable: true却不带该 header 的 503——init_timeout和acp_channel_unavailable都带,runtime_still_starting甚至没声明 retryable 也带。已在线上确认(Retry-After present? false)。 runtime_recycling没有进文档。 所有同类错误码都在docs/developers/qwen-serve-protocol.md和docs/users/qwen-serve.md里;最自然的位置是POST /session错误列表中503 acp_channel_unavailable那一条旁边。目前这个码只存在于新增的设计说明里。- 没有测试。 这个 diff 新增了一个三态状态机、一个新错误类、一个新的公开接口方法和两条新的线路映射,却没有碰任何测试文件。
grep找不到任何测试引用BridgeRuntimeRecyclingError或runtime_recycling。考虑到上面那个 blocker 是被一个无关的既有测试逮到的,这个状态机目前是完全没有守卫的。 requestRuntimeRecycle在本 PR 中是死代码 — 只有声明和实现,在 fix(daemon): recycle unresponsive agent runtimes #11275 之前没有任何调用方。作为 stack 的拆分方式没问题,但意味着这个 recycle 入口是未经验证就合入的。isDying的 setter 会静默吞掉false。set isDying(v) { if (v) this.state = 'dying'; }让ci.isDying = false变成 no-op。目前 10 处赋值全是= true,所以没有实际 bug——但这是给后来人挖的坑,至少值得加一句注释。
结论
建议 changes requested:把 aliveChannels 上限修好让测试转绿,之后这个 PR 就可以合。其余几条建议顺手一起处理,但单独拿出来都不足以卡住合并。




What this PR does
This PR adds the reusable runtime-generation layer for background-Agent recovery. ACP child generations now move explicitly through active, draining, and dying states. A recycle request drains the generation that owns the requesting Session, keeps every existing Session pinned to that owner, and prepares a fresh active generation for new work. The bridge permits at most two OS-live generations; when both slots are occupied and no active generation is available, REST and ACP callers receive a retryable
503 runtime_recyclingresponse.Why it is needed
A child can remain physically alive after it becomes unsafe for fresh work. The previous bridge had enough pieces to retire a channel and remember Session ownership, but it represented only an implicit alive/dying overlap and merely warned if a third child appeared. That was insufficient for the non-cooperative Agent escalation planned in the next layer.
Reviewer Test Plan
How to verify
Review the three lifecycle transitions: fresh work uses the active generation; a recycle request leaves existing Sessions on their recorded generation while a replacement becomes active; and a second recycle while an older generation is still alive cannot create a third child and returns
runtime_recycling. Also confirm that a draining generation remains covered by channel liveness and active-work reporting until it exits.Evidence (Before & After)
N/A — daemon lifecycle behavior with no UI change.
Tested on
No local test, build, typecheck, or CI command was run. The changed files were formatted,
git diff --checkpassed, and the complete diff received two clean static review passes.Risk & Scope
Linked Issues
Part of #8586.
Depends on #11270.
中文说明
这个 PR 做了什么
这个 PR 为后台 Agent 恢复增加了可复用的 runtime generation 层。ACP 子进程 generation 现在显式经过 active、draining 和 dying 三个状态。recycle 请求只 drain 发起请求的 Session 所属 generation,现有 Session 继续固定路由到原 owner,新工作则使用新的 active generation。bridge 最多允许两个 OS 层仍存活的 generation;如果两个槽位都被占用且没有 active generation,REST 和 ACP 调用会收到可重试的
503 runtime_recycling。为什么需要
子进程可能仍然物理存活,但已经不适合接收新工作。之前 bridge 已经具备延迟退役 channel 和记录 Session owner 的零件,但只有隐式的 alive/dying 重叠,而且第三个子进程出现时只报警,无法支撑下一层的非协作 Agent 升级恢复。
Reviewer Test Plan
如何验证
检查三个生命周期转换:新工作只使用 active generation;recycle 后现有 Session 仍走原 generation,同时新的 generation 成为 active;当旧 generation 尚未退出又触发第二次 recycle 时,不会创建第三个子进程,而是返回
runtime_recycling。同时确认 draining generation 在退出前仍受 channel liveness 和 active-work reporting 覆盖。前后证据
N/A——这是 daemon 生命周期行为变更,没有 UI 变化。
验证环境
三个系统均为 N/A。没有运行本地 test、build、typecheck 或 CI 命令;仅格式化改动文件、通过
git diff --check,并对完整 diff 完成两遍干净的静态审查。风险与范围
关联 Issue
属于 #8586 的一部分。
依赖 #11270。