Skip to content

feat(serve): add budget-based ACP child admission - #11911

Merged
doudouOUC merged 3 commits into
mainfrom
codex/acp-capacity-admission-pr1
Sep 15, 2026
Merged

doudouOUC merged 3 commits into
mainfrom
codex/acp-capacity-admission-pr1

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

Adds opt-in qwen serve --child-heap-mode admit, using the existing initialized budget to limit concurrent managed ACP children. Reservations and terminating children count until release; workspace registration and reuse of an existing child do not consume another slot. REST and ACP return a recognizable capacity error, including through verified standalone rollback. Web Shell stops automatic capacity retries and preserves drafts, including the first /goal command. Status distinguishes enforced process admission from advisory heap sizing.

Why it's needed

The current observe policy reports a modeled limit but permits starts beyond it. This implements stage 1 of #11907 so operators can enforce predictable process concurrency and users receive actionable feedback when a cold workspace cannot start.

Reviewer Test Plan

How to verify

Start an isolated daemon with two workspaces, --memory-budget-mb 1024 and --child-heap-mode admit on a host that models one slot. Keep the primary ACP resident, then create or restore a session in the cold secondary. Expect REST 503 with acp_child_capacity_exhausted, or equivalent ACP error metadata, without Retry-After. Existing-child sessions and workspace registration should still work. Release the tracked child and deliberately retry; the secondary should start in its own workspace.

In Web Shell, submit a first message in that cold workspace. Expect a capacity notice, retained text, an enabled send button and no automatic repeated creation. Also verify /goal and shell drafts are retained on creation rejection. Repeat the daemon comparison with observe: the extra child should still be allowed, and child heap arguments should match both modes.

Evidence (Before & After)

Before: global Qwen Code 0.22.3 with one modeled slot started two ACP children under observe; admit was rejected by the parser. After: isolated bundle tests passed 33 admission checks; cold create, runtime ensure and persisted-session load returned capacity 503, ACP SSE carried equivalent metadata, refused reservations were released, and manual retry succeeded after a test-owned child exited. Both modes retained --max-old-space-size=16384 --expose-gc on this host.

Real Chromium verification passed 10 checks: the capacity toast was visible, the exact draft remained after 40 seconds, and submission caused one create request with no subsequent automatic create/load/ensure requests. Component tests cover Chinese notices, goal/shell draft preservation and readable automatic-load errors.

Tested on

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

Environment (optional)

macOS, Node 22.22.3, locally built CLI/Web Shell and cached Playwright Chromium. Daemons used isolated temporary directories, fake credentials and no real model requests. Build, typecheck, changed-code formatting/lint and focused tests passed. The complete server suite passed 1,278 tests with a test-only IPv6 request-address correction: ordinary runs could hit unrelated local IPv4 listeners because Supertest listens on :: but requests 127.0.0.1. No production or global network workaround is included. Web component tests disabled Node's experimental Web Storage for jsdom.

Risk & Scope

  • Main risk or tradeoff: This limits process count, not live RAM or child heap usage. Full occupancy can reject channel replacement while the old child is still tracked; existing children keep their legacy heap ceilings.
  • Not validated / out of scope: Real low-memory/model load, natural ten-minute keep-alive expiry, Windows/Linux runtime behavior, idle reclamation and a workspace-closure chooser. The release test terminated only its own ACP after closing its sessions. Browser coverage is the English first-message path; other UI paths use component tests.
  • Breaking changes / migration notes: Default remains observe; opt in with --child-heap-mode admit. No new environment variables. A zero-slot admission configuration and an injected/unwired admitting bridge are rejected at startup. PR2 and PR3 remain separate follow-ups.

Synchronized design: English and 简体中文.

Linked Issues

Implements stage 1 of #11907. Related to #8182; background: #11386. Does not close the three-stage tracker or the fixed-heap enforcement issue.

中文说明

What this PR does

新增显式开启的 qwen serve --child-heap-mode admit,复用已有初始化预算限制托管 ACP 子进程并发数量。预留启动和终止中的进程在登记表释放前继续计数;注册 workspace 和复用已有子进程不新增名额。REST/ACP 返回可识别的容量错误,并在已验证的 standalone 回滚中保留容量原因。Web Shell 停止自动容量重试并保留草稿,包括首次 /goal 命令。状态接口区分实际执行的数量准入和仅供参考的堆模型。

Why it's needed

当前 observe 策略只报告模型上限,仍允许超额启动。本 PR 实现 #11907 的第一阶段,让运维可以限制进程并发,并在冷 workspace 无法启动时向用户提供可操作的提示。

Reviewer Test Plan

How to verify

在可计算出一个名额的宿主上,用两个 workspace、--memory-budget-mb 1024--child-heap-mode admit 启动隔离 daemon。保持 primary ACP 常驻,再在冷 secondary 中创建或恢复会话。应收到 REST 503 和 acp_child_capacity_exhausted,或等价 ACP 错误元数据,不包含 Retry-After。已有子进程中的会话和 workspace 注册仍应可用。释放被跟踪子进程后主动重试,secondary 应在自己的 workspace 中启动。

在 Web Shell 向该冷 workspace 提交首条消息,应看到容量提示、保留的输入、可用的发送按钮,且不会自动重复创建。另验证 /goal 和 shell 草稿在创建失败后保留。使用 observe 重复 daemon 对照:仍应允许额外子进程,且两种模式的子进程堆参数一致。

Evidence (Before & After)

变更前:全局 Qwen Code 0.22.3 在模型只有一个名额时,observe 仍启动了两个 ACP;解析器拒绝 admit。变更后:隔离 bundle 的 33 项准入检查通过;冷创建、runtime ensure 和持久化会话加载均返回容量 503,ACP SSE 携带等价元数据,拒绝预留得到释放,测试自有子进程退出后主动重试成功。两种模式在该宿主上都保留 --max-old-space-size=16384 --expose-gc

真实 Chromium 验证 10 项通过:容量 toast 可见,40 秒后输入完全保留,提交只产生一次创建请求,随后没有自动 create/load/ensure 请求。组件测试覆盖中文提示、goal/shell 草稿保留和自动加载的可读错误。

Tested on

OS Status
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

Environment (optional)

macOS、Node 22.22.3、本地构建的 CLI/Web Shell 和已缓存的 Playwright Chromium。Daemon 使用隔离临时目录、虚假凭证,不调用真实模型。构建、类型检查、变更代码格式/lint 和定向测试通过。完整服务端测试在仅用于测试的 IPv6 请求地址修正下通过 1,278 项:普通运行中,Supertest 监听 :: 却请求 127.0.0.1,可能误入本机无关 IPv4 服务。本 PR 不包含生产或全局网络绕过。Web 组件测试为 jsdom 关闭了 Node 的实验性 Web Storage。

Risk & Scope

  • Main risk or tradeoff: 限制的是进程数量,并非实时内存或子进程堆用量。旧进程尚被跟踪时,满额可能拒绝 channel 替换;已有子进程仍保留原有堆上限。
  • Not validated / out of scope: 真实低内存/模型负载、自然十分钟 keep-alive 到期、Windows/Linux 运行行为、空闲回收和 workspace 关闭选择器。释放测试仅在关闭会话后终止测试自己的 ACP。浏览器覆盖英文首条消息路径,其余 UI 路径使用组件测试。
  • Breaking changes / migration notes: 默认仍为 observe,通过 --child-heap-mode admit 显式开启。不新增环境变量。零名额的准入配置,以及注入或未接入共享准入的 bridge,会在启动时被拒绝。PR2 和 PR3 留作独立后续工作。

完整同步的设计文档:English简体中文

Linked Issues

实现 #11907 第一阶段。关联 #8182,背景为 #11386。不关闭三阶段 tracker 或固定堆限额 issue。

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

Copy link
Copy Markdown
Collaborator Author

E2E verification report

Local verification on macOS / Node 22.22.3, using isolated temporary workspaces and no real model requests:

Scenario Result
Global CLI baseline Qwen Code 0.22.3 allows 2 ACP children with 1 modeled slot under observe; rejects the new admit option.
Observe comparison 11 checks passed; session reuse and full-capacity registration work, and an extra child remains allowed.
Admit REST / ACP 33 checks passed; cold creation, runtime ensure and synthetic persisted-session load return capacity 503. ACP replies are read from SSE after the HTTP 202 acknowledgment and carry equivalent error metadata.
Registry lifecycle Rejected reservations do not leak; closing sessions and terminating only the test-owned ACP releases its slot; a deliberate secondary-workspace retry succeeds.
Heap arguments Both modes retain --max-old-space-size=16384 --expose-gc on this host; the modeled 768 MiB share is not applied.
Zero-slot startup Synthetic host-memory input produces exit 1 and no listener, with the explicit admission configuration error.
Real browser Final run: 10/10 passed. Visible capacity toast, exact draft retained for 40,113 ms, send enabled, one create after submission and no subsequent automatic create/load/ensure requests. No network route mocks.

The final API/ACP run used CLI bundle SHA256 05a1a67f4dbd344596e892f3cf45bbcf78b827a4ffad83bdb3a47bc5eaa3efd8. The final browser additionally verified the loaded Web Shell JavaScript SHA256 da0d267c01b9e6c2d7eceb3320f1c40279661662b5af64cc188b6487d7ab87ff, including the reviewed Provider fix. All test-owned browsers, daemons and temporary directories were cleaned up.

Build, typecheck, formatting/lint and focused tests passed. The complete server suite passed 1,278/1,278 with a runtime-only Supertest address correction, proven active for 1,774 requests. On this macOS host, default IPv6 listening can reuse a port held by an unrelated IPv4 listener while Supertest constructs an IPv4 request URL; ordinary runs therefore sometimes returned another service's response. An owned-server probe reproduced this. The original failing runs are not relabeled green, and no dependency file, production code or global network setting was changed for the workaround.

Limits: the browser verifies English first-message creation, not the Provider cold-load branch; that branch is covered by the 377-test Provider suite, including a readable-error regression. Goal/shell draft preservation and Chinese notices are covered by App component tests. Natural ten-minute keep-alive expiry, real low-memory/model load, Windows/Linux runtime behavior and MCP mutation E2E were not exercised. MCP GET was metadata-only and is not presented as cold-start evidence.

中文说明

E2E 验证报告

本地 macOS / Node 22.22.3,使用隔离临时 workspace,不调用真实模型:

场景 结果
全局 CLI 基线 Qwen Code 0.22.3 在 observe 模型只有 1 个名额时允许 2 个 ACP,拒绝新增的 admit 参数值。
Observe 对照 11 项通过;会话复用和满额时注册可用,仍允许额外子进程。
Admit REST / ACP 33 项通过;冷创建、runtime ensure 和合成持久化会话加载返回容量 503。ACP 在 HTTP 202 确认后从 SSE 读取结果,携带等价错误元数据。
登记表生命周期 拒绝预留不泄漏;关闭会话后,仅终止测试自有 ACP 即可释放名额,secondary workspace 主动重试成功。
堆参数 两种模式均保留该宿主的 --max-old-space-size=16384 --expose-gc,没有应用模型中的 768 MiB 份额。
零名额启动 合成宿主内存输入触发退出码 1,无监听器,并报告明确的准入配置错误。
真实浏览器 最终 10/10 通过。容量 toast 可见,40,113 毫秒后输入完全保留,发送可用;提交后仅一次创建,随后无自动 create/load/ensure 请求。没有网络路由 mock。

最终 API/ACP 使用 CLI bundle SHA256 05a1a67f4dbd344596e892f3cf45bbcf78b827a4ffad83bdb3a47bc5eaa3efd8。最终浏览器另核实了实际加载的 Web Shell JavaScript SHA256 da0d267c01b9e6c2d7eceb3320f1c40279661662b5af64cc188b6487d7ab87ff,包含审查后的 Provider 修复。所有测试自有浏览器、daemon 和临时目录均已清理。

构建、类型检查、格式/lint 和定向测试通过。完整服务端测试通过 1,278/1,278,使用仅在运行时生效的 Supertest 地址修正,已证明实际作用于 1,774 个请求。该 macOS 宿主上,默认 IPv6 监听可能使用已被无关 IPv4 服务占用的端口,而 Supertest 构造 IPv4 请求 URL,因此普通运行有时会收到其他服务的响应。自有服务探测已复现该机制。原始失败不重新标记为通过;该测试绕过没有修改依赖文件、生产代码或全局网络设置。

边界:浏览器验证英文首条消息创建,不覆盖 Provider 冷加载分支;后者由 377 项 Provider 测试覆盖,包括可读错误回归用例。Goal/shell 草稿保留及中文提示由 App 组件测试覆盖。未验证自然十分钟 keep-alive 到期、真实低内存/模型负载、Windows/Linux 运行行为,以及 MCP 变更 E2E。MCP GET 只读元数据,不作为冷启动证据。

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 41a09ac. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

ℹ️ No screenshot changed against the PR base — but this PR edits 4 render-shaping files:

  • packages/web-shell/client/App.tsx
  • packages/web-shell/client/components/mcp/McpManagerPage.tsx
  • packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx
  • packages/web-shell/client/i18n.tsx

Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to packages/web-shell/client/e2e/visuals/screenshots.spec.ts that seeds whatever state the UI is gated on; it then appears here as a head-only (NEW) capture.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

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

health-deep-with-session

field PR base (before) this PR (after)
activeWorkStaleMs 6 5

Qwen Code · serve A/B

@doudouOUC doudouOUC self-assigned this Sep 15, 2026
@doudouOUC
doudouOUC marked this pull request as ready for review September 15, 2026 06:15
@doudouOUC
doudouOUC enabled auto-merge September 15, 2026 06:15
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

doudouOUC and others added 2 commits September 15, 2026 14:23
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Assert the admissionEnforced field and its false value in default observe mode.

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

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

Copy link
Copy Markdown
Collaborator

Re-ran the gate on 41a09ac (head hasn't moved since the last pass, and CI on it has now finished). Nothing here changes the earlier read — recording it so all three comments describe the same commit.

Thanks for the PR, and for filing the tracker (#11907) first. Gating a stage-1 slice against written acceptance criteria is much easier than guessing intent.

Template looks good ✓ — all nine headings, bilingual body, and the design doc in both languages.

Problem: observed, not theoretical. #8182 records that every ACP child is authorised 50% of host memory and never divided by child count, and the tracker records that observe publishes a modeled limit while permitting starts past it. Your Before evidence is concrete and falsifiable: 0.22.3 on a host modeling one slot started two children under observe, and the parser rejected admit. That is a real gap.

Direction: aligned. This is stage 1 of a maintainer-filed tracker and it stays inside the stated criteria — no reclamation, no workspace chooser, no heap-argument change, no new env var, default still observe. Opt-in is the right blast radius for a daemon admission change. Reference signal: the claude-code CHANGELOG shows the same shape of decision on the agent side — a cap on concurrently-running subagents (default 20, CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS) "so one message can't fan out unbounded background agents", and later removing the 200-per-session spawn cap while explicitly keeping concurrency and depth limits. Bounded process concurrency with a recognizable refusal is an established pattern, not a novel one.

Size: cross-package (acp-bridge, cli, sdk-typescript, web-shell), so Stage 0 applies. Of the 1321 changed lines: 425 production, 462 test, 434 docs, 0 generated/schema. feat type, so no Tier-1 concern; 425 production lines is under both the 500-line maintainer-awareness threshold and the 1000-line large-PR advisory. You have admin on the repo, so this is maintainer-authored and the external two-tier gate is exempt regardless.

Approach: scope matches the tracker, and leaving PR2/PR3 out is the right call. Two things I'd still push on, neither a blocker:

  • The eight near-identical isAcpChildCapacityError(error) ? t('daemon.capacity.exhausted') : extractErrorDetail(error) branches in McpManagerPage.tsx are a lot of repetition for one mapping decision. A tiny local helper collapses them and stops the next error class from needing eight more edits.
  • The /goal change does more than retain a draft: it defers the composer commit, adds an in-flight ref, and changes the handler's return contract from always-true to !needsSession. I checked it against the call site and it is right — commitComposerAccepted is the existing ComposerSubmitCommit contract that /fork and the prompt path already use, so nothing parallel was invented, and the .finally resets the ref on every exit path. But it is a behavior change to a flow the tracker only asks you not to lose input on, so it deserves a sentence in the description saying why the deferral had to move here rather than in a follow-up.

Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths. The real operational risk is the one you documented yourself: at full occupancy a channel replacement can be refused while the old child is still tracked, so a workspace can be unable to restart until teardown completes. PR2's idle reclamation is the relief, and it is worth restating for anyone who opts in before PR2 lands.

Moving on to code review. 🔍

中文说明

41a09ac 上重新跑了一遍门禁(head 自上次审查后没有变动,该 commit 的 CI 现已跑完)。结论与上次一致——记录下来是为了让三条评论描述同一个 commit。

感谢贡献,也感谢先写了 tracker(#11907)。按书面验收标准审查第一阶段切片,比猜测意图容易得多。

模板完整 ✓ —— 九个标题齐全,正文双语,设计文档两种语言都有。

问题: 是已观测到的问题,不是理论性加固。#8182 记录了每个 ACP 子进程都被授权主机内存的 50%、且从不按子进程数平分;tracker 记录了 observe 会公布一个模型上限但允许超过它继续启动。你的 Before 证据具体且可证伪:在只建模一个名额的主机上,0.22.3 在 observe 下启动了两个子进程,而解析器拒绝 admit。这是真实缺口。

方向: 对齐。这是维护者自己提的 tracker 的第一阶段,且严格停留在既定验收标准内——不做回收、不做 workspace 选择器、不改子进程堆参数、不新增环境变量、默认仍是 observe。对 daemon 准入改动来说,显式开启是合适的影响面。参考信号:claude-code 的 CHANGELOG 在 agent 侧有同样形态的决定——对并发运行的子 agent 设上限(默认 20,CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS),"以免一条消息无界地扇出后台 agent";后来又移除了每会话 200 个的硬性上限,但明确保留并发与深度限制。有界进程并发加上可识别的拒绝,是既有模式而非新发明。

规模: 跨包改动(acp-bridgeclisdk-typescriptweb-shell),因此适用 Stage 0。1321 行改动的构成:生产代码 425 行测试 462 行文档 434 行生成/schema 0 行。类型是 feat,不涉及 Tier-1;425 行生产代码低于 500 行的维护者关注阈值和 1000 行的大 PR 建议阈值。你在本仓库有 admin 权限,属于维护者自己提的 PR,外部 PR 的两级门禁本来也不适用。

方案: 范围与 tracker 一致,PR2/PR3 不塞进来是正确的。仍有两点想提出来,都不是阻塞项:

  • McpManagerPage.tsx 里八处几乎完全相同的 isAcpChildCapacityError(error) ? t('daemon.capacity.exhausted') : extractErrorDetail(error) 分支,为了一个映射决定重复太多次。一个很小的本地 helper 就能收拢,也避免下一种错误类型又要改八处。
  • /goal 的改动比"保留草稿"做得更多:推迟了 composer 提交、新增 in-flight ref,并把处理函数的返回约定从恒为 true 改成 !needsSession。我对着调用点核过了,是对的——commitComposerAccepted/fork 和 prompt 路径已在用的既有 ComposerSubmitCommit 约定,没有另造一套,且 .finally 在所有出口都会复位。但这毕竟是对 tracker 只要求"别丢输入"的流程做了行为改动,建议在描述里补一句:为什么这个推迟必须放在本 PR,而不是后续 PR。

风险: 无升级风险信号——变更文件都没有命中与 revert 相关的高风险路径。真正的运维风险是你自己已经写明的:满名额时,旧子进程仍被跟踪期间,channel 替换可能被拒绝,因此 workspace 在 teardown 完成前可能无法重启。PR2 的空闲回收是解法;在 PR2 落地前开启该模式的人值得再被提醒一次。

进入代码审查 🔍

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

My own baseline for "enforce a concurrent ACP child limit" was a counter plus a mutex in the bridge, checked before spawn, with a new error type. What shipped is better than that: the reservation already existed in ProcessRegistry for exactly this purpose, the modeled slot count already existed in ChildHeapPolicy, and the wire code slots into error surfaces that already classify bridge failures. Nothing parallel was built.

What I checked hardest, and what I found — each of these I read at 41a09ac, not from the description:

  • No off-by-one in the slot arithmetic. This is the part that would have been embarrassing to get wrong, so I traced it. committedProcessCount is children.size + reservations.size, and reserve() inserts its token synchronously before decide(), so the count a spawn sees already includes itself. decide() refuses on concurrentChildren > maxConcurrentChildren. Those two choices cancel out exactly: with one modeled slot, the first spawn sees 1, 1 > 1 is false, it is admitted; a second concurrent spawn sees 2 and is refused. The invariant is "committed never exceeds the limit after an admit", which is the one you want.
  • A refusal cannot leak a slot. The throw sits inside the try whose catch is reservation.cancel(); throw error; — it rethrows rather than swallowing, and the cancel happens before any spawn(), so there is no orphaned child either. committedAcpChildren is reported as committedProcessCount - 1, i.e. excluding the reservation about to be cancelled, which is the honest number to hand a client.
  • The count is genuinely daemon-wide. There are four production createSpawnChannelFactory call sites, and I checked all four rather than assuming: the three in run-qwen-serve.ts (primary, secondary, per-workspace) all pass the same processRegistry declared once and the same childHeapPolicy declared once, and server.ts receives both through managedChildProcesses. Because admissionPolicy ?? createChildHeapPolicy(...) short-circuits, a second policy instance — with its own refusals counter and no registry behind it — is never constructed. So the limit is per-daemon, not per-factory.
  • admit fails closed in four separate places, all at construction or startup rather than at first refusal: a budget that models zero children, a factory handed an admitting policy without an explicit registry, an injected bridge combined with admit, and createServeApp called with childHeapMode: 'admit' but no admitting wiring. None of them silently degrades to observe. The bootstrap app is a different function, so the bootstrap window is unaffected.
  • The error survives every path to a client. In sendBridgeError the capacity branch sits before the generic WorkspaceRuntimeInitializationError branch and unwraps one level of .cause; toRpcError mirrors it. The coordinator rethrows the raw error at three sites instead of wrapping it — I grepped every read site of WorkspaceRuntimeInitializationError to be sure nothing keys on that wrapper and would now miss the capacity case, and the only production reader is the same error-response.ts branch that already unwraps .cause. So the raw rethrow bypasses no handler.
  • The rollback path keeps its own outcome and still reads as capacity. standalone_creation_rolled_back stays the code, with the capacity triple nested under capacity, which is why the recognizer checks five shapes rather than one. Quarantine precedence for the uncertain outcome is untouched.
  • Retry-After is deliberately suppressed for capacity (err.retryable && !err.capacity), and the provider drops capacityRejected out of the auto-reconnect condition. That combination is what actually stops a client loop instead of merely reporting one.
  • observe is untouched. decide() is still called exactly once per spawn, the new branch only acts when the snapshot says admit, and argv is unchanged. The Serve A/B lane backs this up independently — it built base vs head, drove a fixed endpoint set against each, and the only differing field across the whole diff was activeWorkStaleMs going 6 → 5, which is timing jitter. That is a stronger no-regression signal than the unit tests alone.

Two non-blocking findings. Neither is a correctness problem and neither should hold the merge:

  • DaemonSessionProvider.tsx hardcodes getTranslator('en') for the capacity message that lands in connection.error, and the leak is a bit wider than "add a comment". ChatPane.tsx:1627 renders {t('splitView.paneConnectionError')}: {connection.error}, so a zh-CN user gets a localized label followed by an English sentence. Separately, App.tsx:12190 re-reports that value as onError(new Error(connection.error)) — a plain Error, not a DaemonHttpError, so isAcpChildCapacityError returns false there and reportError toasts the English text instead of the t('daemon.capacity.exhausted') this PR added. Net effect: on the automatic-load path the new localization is bypassed by the very string it was meant to localize. Content is accurate, so this is cosmetic — but if no language is genuinely in scope inside that provider, say so in a one-line comment, because otherwise the next reader will assume it is an oversight. Every other component that needs this derives the language (WorkspaceSessionProvider from webShellProps.language, App from selectedLanguage).
  • The eight duplicated capacity branches in McpManagerPage.tsx, as raised in the gate comment.

One thing to keep visible, not a defect: you documented it yourself, but it is the sharpest edge of turning admit on today — at full occupancy, replacing a channel can be refused while the old child is still tracked, so a workspace can be unable to restart until teardown completes. PR2's idle reclamation is the relief. Worth restating for whoever opts in before PR2 lands.

sequenceDiagram
    participant P1 as Client REST or ACP
    participant P2 as standalone session service
    participant P3 as spawnChannel factory
    participant P4 as ProcessRegistry
    participant P5 as ChildHeapPolicy
    participant P6 as error mapper
    participant P7 as Web Shell
    P1->>P2: create or load a session in a cold workspace
    P2->>P3: spawn an ACP child
    P3->>P4: reserve a slot before deciding
    P3->>P5: decide using the committed count
    P5-->>P3: refuse, count is past the modeled limit
    P3->>P4: cancel the reservation
    P3-->>P2: AcpChildCapacityExceededError
    P2-->>P6: rolled back, carrying the capacity cause
    P6-->>P1: 503 with code acp_child_capacity_exhausted, no Retry-After
    P1->>P7: surface the error
    P7-->>P7: stop automatic retries, keep the draft
Loading
Files changed (23 production files of 43; 14 test files and 6 docs omitted)
File What changed
packages/acp-bridge/src/child-heap-policy.ts adds the admit mode and refuses to build an admitting policy that models zero children
packages/acp-bridge/src/bridgeErrors.ts the shared typed capacity error, its wire code and the two figures it carries
packages/acp-bridge/src/spawnChannel.ts acts on the decision in admit mode, requires an explicit shared registry
packages/cli/src/commands/serve.ts admit added to the flag choices and its description
packages/cli/src/serve/fast-path.ts fast-path parser accepts admit instead of falling back to yargs
packages/cli/src/serve/run-qwen-serve.ts builds one shared admitting policy, rejects an injected bridge, wires registry and policy into the app
packages/cli/src/serve/server.ts fails closed without wiring, threads registry and policy into the spawn factory and status
packages/cli/src/serve/types.ts ServeOptions doc comments updated for admit
packages/cli/src/serve/daemon-status.ts admissionEnforced on childHeap, committedAcpChildren on runtime memory
packages/cli/src/serve/routes/daemon-status.ts passes the two new status inputs through
packages/cli/src/serve/server/error-response.ts REST capacity branch, 503, no Retry-After, capacity nested on rollback
packages/cli/src/serve/acp-http/dispatch.ts RPC capacity mapping with errorKind and httpStatus in data
packages/cli/src/serve/acp-session-bridge.ts re-exports the new error for cli-side consumers
packages/cli/src/serve/conversations/standalone-session-service.ts preserves the capacity cause through a verified rollback
packages/cli/src/serve/workspace-runtime-coordinator.ts rethrows the raw capacity error on preheat and MCP mutation paths
packages/sdk-typescript/src/daemon/types.ts optional admissionEnforced and committedAcpChildren, additive for older daemons
packages/web-shell/client/daemon/session/httpErrors.ts recognizer covering all five REST and RPC body shapes
packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx stops automatic load retries on capacity, readable reason
packages/web-shell/client/daemon/session/actions.ts tags the notice with the capacity code so the UI can localize it
packages/web-shell/client/App.tsx localized capacity toast and notice, goal composer commit deferral
packages/web-shell/client/components/mcp/McpManagerPage.tsx capacity-aware messages on the eight MCP mutation paths
packages/web-shell/client/i18n.tsx the new message in English and Chinese
integration-tests/tsconfig.json path mapping for the processRegistry entry

Testing

Which evidence this carries: this is an unattended CI run, so the review is static — I built nothing and executed no PR-derived code. Everything below is the PR's own CI, read through the API for the reviewed commit. The author's macOS/Chromium results in the description are the author's claim, not evidence I re-ran.

All 24 checks on 41a09ac are complete: 21 success, 3 skipped, 0 failure, and all six pull_request workflow runs finished green.

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

Two honest caveats on that green:

  • The skipped lanes include the macOS and Windows unit jobs, so the suite ran on ubuntu only. That lines up with your own "Windows/Linux not tested" — the platform gap is declared on both sides, not hidden.
  • Green here proves the tests pass, not that they pin admit. No CI lane starts a daemon with --child-heap-mode admit; the default is observe and Serve A/B drives a fixed endpoint set at defaults. The unit coverage is genuinely good — 462 test lines across 14 files, including the cross-factory slot-release case and the admissionEnforced === false assertion under the default — but it is all in-process.

Sandboxed verification would settle exactly that gap, and it is already running: this re-run dispatched a verify job alongside triage, so a /verify report should land as its own comment on this PR. The claim it needs to settle is specific — that a real daemon in admit refuses the N+1 child at the modeled slot count, releases the reservation so a deliberate retry after teardown succeeds, and returns acp_child_capacity_exhausted rather than a generic reconnect or rollback error. None of those three is observable from the diff, and the suite would pass identically with the decision?.refuse branch deleted. If that report does not appear, re-trigger it with @qwen-code /verify before or shortly after merge — it matters more for PR2, which builds directly on this admission path.

Not verified: Windows and Linux runtime behavior (no lane covers them). Not verified: that a capacity error delivered over the ACP SSE stream always reaches the recognizer as a DaemonHttpError — the recognizer requires that class, the component tests assert the shapes, and I did not execute them.

中文说明

代码审查

我自己对"限制并发 ACP 子进程"的基线方案是:在 bridge 里加计数器加互斥锁,spawn 前检查,再配一个新错误类型。实际交付的比这个更好:ProcessRegistry 里本来就有为此准备的预留机制,ChildHeapPolicy 里本来就有建模出的名额数,wire code 也正好落进已经在给 bridge 失败分类的错误面上。没有另建一套平行实现。

我核得最硬的几点,都是在 41a09ac 上直接读代码得到的,不是照抄描述:

  • 名额计算没有差一错误。 这是最容易出错、错了也最难看的地方,所以我完整追了一遍。committedProcessCountchildren.size + reservations.size,而 reserve()decide() 之前同步插入 token,所以一次 spawn 看到的计数已经包含它自己。decide() 的拒绝条件是 concurrentChildren > maxConcurrentChildren。这两个选择恰好抵消:建模一个名额时,第一次 spawn 看到 1,1 > 1 为假,获准;并发的第二次看到 2,被拒。不变量是"准入后 committed 不超过上限",正是想要的那条。
  • 拒绝不会泄漏名额。 throw 位于 catchreservation.cancel(); throw error;try 内——是重新抛出而非吞掉,且取消发生在任何 spawn() 之前,所以也不会留下孤儿子进程。上报的 committedAcpChildrencommittedProcessCount - 1,即排除即将被取消的那次预留,这是给客户端的诚实数字。
  • 计数确实是 daemon 级的。 生产代码里有四处 createSpawnChannelFactory 调用点,我四处都看了而不是想当然:run-qwen-serve.ts 里的三处(primary、secondary、per-workspace)都传同一个只声明一次的 processRegistry 和同一个只声明一次的 childHeapPolicyserver.ts 则通过 managedChildProcesses 拿到这两者。由于 admissionPolicy ?? createChildHeapPolicy(...) 会短路,第二个 policy 实例(自带 refusals 计数、背后没有 registry)永远不会被构造。所以限额是每 daemon 的,不是每 factory 的。
  • admit 在四个不同位置 fail-closed, 全部发生在构造或启动期而不是首次拒绝时:预算建模出零个名额、工厂拿到准入 policy 却没有显式 registry、注入 bridge 同时开启 admit、以及 createServeApp 收到 childHeapMode: 'admit' 却没有准入接线。没有一处会静默降级成 observe。bootstrap app 是另一个函数,因此 bootstrap 窗口不受影响。
  • 错误在抵达客户端的每条路径上都存活。 sendBridgeError 里容量分支位于通用 WorkspaceRuntimeInitializationError 分支之前,并解一层 .causetoRpcError 与之对应。coordinator 在三处改为重新抛出原始错误而不再包装——我 grep 了 WorkspaceRuntimeInitializationError 的每一个读取点,确认没有别处依赖这个包装类而因此漏掉容量场景,唯一的生产读取点正是那个已经会解 .causeerror-response.ts 分支。所以裸抛没有绕过任何处理器。
  • 回滚路径保留自己的结果,同时仍可被识别为容量错误。 code 仍是 standalone_creation_rolled_back,容量三元组嵌在 capacity 下——这正是识别函数要检查五种形态而不是一种的原因。副作用不确定时的隔离优先级未受影响。
  • Retry-After 是刻意抑制的err.retryable && !err.capacity),且 provider 把 capacityRejected 从自动重连条件里排除了。这个组合才真正阻止了客户端循环,而不只是上报循环。
  • observe 未被触动。 每次 spawn 仍只调用一次 decide(),新分支只在快照为 admit 时动作,argv 不变。Serve A/B 通道独立佐证了这一点——它分别构建 base 与 head、对同一组固定端点驱动并比对,整个 diff 中唯一不同的字段是 activeWorkStaleMs 从 6 变 5,属于计时抖动。这比单靠单元测试是更强的无回归信号。

两条非阻塞发现。 都不是正确性问题,都不应挡住合并:

  • DaemonSessionProvider.tsx 对落入 connection.error 的容量消息硬编码了 getTranslator('en'),而且泄漏范围比"加个注释"要宽一点。ChatPane.tsx:1627 渲染 {t('splitView.paneConnectionError')}: {connection.error},所以 zh-CN 用户会看到本地化标签后面跟一句英文。另外 App.tsx:12190 会把该值以 onError(new Error(connection.error)) 再上报一次——那是普通 Error 而非 DaemonHttpError,因此 isAcpChildCapacityError 在那里返回 false,reportError 会 toast 出英文原文,而不是本 PR 新增的 t('daemon.capacity.exhausted')。最终效果是:在自动加载路径上,新增的本地化被它本想本地化的那个字符串绕过了。内容本身准确,所以属于外观问题——但如果那个 provider 里确实拿不到语言,请加一行注释说明,否则下一个读者会以为是疏漏。其他需要这个信息的组件都是推导语言的(WorkspaceSessionProviderwebShellProps.languageAppselectedLanguage)。
  • McpManagerPage.tsx 里八处重复的容量分支,与门禁评论中提的一致。

一件需要保持可见的事,不是缺陷: 你自己已经写明,但这是今天开启 admit 最锋利的一条边——满名额时,旧子进程仍被跟踪期间替换 channel 可能被拒绝,因此 workspace 在 teardown 完成前可能无法重启。PR2 的空闲回收是解法。在 PR2 落地前开启该模式的人值得再被提醒一次。

(流程图与文件清单见英文部分,此处不重复。)

测试

本节承载的是哪种证据: 这是无人值守的 CI 运行,因此审查是静态的——没有构建任何东西,也没有执行任何 PR 派生代码。下面全部是通过 API 读到的、该 PR 自身在被审 commit 上的 CI 结果。描述里作者的 macOS/Chromium 结果是作者的自述,不是我复跑的证据。

41a09ac 上 24 个检查全部完成:21 成功、3 跳过、0 失败,六个 pull_request workflow run 全绿。

对这份绿色有两点如实说明:

  • 被跳过的通道包含 macOS 与 Windows 的单测 job,所以套件只在 ubuntu 上跑过。这与你自述的"Windows/Linux 未测试"一致——平台缺口是两边都声明过的,没有被掩盖。
  • 这里的绿色证明测试通过,不证明测试钉住了 admit。没有任何 CI 通道以 --child-heap-mode admit 启动 daemon;默认是 observe,Serve A/B 也是以默认参数驱动固定端点集。单元测试覆盖确实不错——14 个文件 462 行测试,包含跨工厂的名额释放场景和默认模式下 admissionEnforced === false 的断言——但全部在进程内。

沙箱验证恰好能补上这个缺口,而且它已经在跑:本次 re-run 在 triage 之外还派发了 verify job,因此 /verify 报告应会作为独立评论发布在本 PR 上。它需要落定的论断很具体——真实 daemon 在 admit 下会在建模名额处拒绝第 N+1 个子进程、会释放预留使得 teardown 后的主动重试成功、并且返回 acp_child_capacity_exhausted 而不是通用重连或回滚错误。 这三点都无法从 diff 观察得到,而且把 decision?.refuse 分支删掉后套件仍会同样通过。如果那份报告没有出现,请在合并前后用 @qwen-code /verify 重新触发——这对 PR2 更重要,因为 PR2 直接构建在这条准入路径上。

未验证:Windows 与 Linux 运行行为(无通道覆盖)。未验证:经 ACP SSE 流送达的容量错误是否总以 DaemonHttpError 抵达识别函数——识别函数要求该类,组件测试断言了各种形态,而我没有执行它们。

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — solid, and I could not break the part that matters. What keeps it off 5 is two cosmetic gaps and the fact that no CI lane has driven a real daemon in admit, so the behavioural core still rests on in-process tests plus your local run.

Stepping back. My independent proposal for this was a counter and a mutex in the bridge, checked before spawn, with a new error type — a small parallel subsystem. What you shipped reuses the reservation ProcessRegistry already had and the slot count ChildHeapPolicy already modeled, so in six months this reads as one more mode on an existing policy rather than as a subsystem. That is the test I actually care about, and it passes.

The accounting is where admission code usually goes wrong, so I went at it expecting an off-by-one and did not find one. Counting the caller's own reservation and refusing on > are the two halves of the same decision, and they cancel correctly: one modeled slot admits the first spawn and refuses the second. The refusal lands inside the try whose catch cancels the reservation and rethrows, before any spawn(), so neither a slot nor a child can leak. And the count is genuinely daemon-wide — I checked all four production factory call sites rather than assuming, and they share one registry and one policy instance, with the ?? short-circuit making a second policy unreachable.

The fail-closed discipline is what I would have asked for and did not have to: zero-slot budget, admitting policy without a registry, admit with an injected bridge, and createServeApp without admitting wiring all refuse at startup instead of quietly degrading to observe. A daemon that silently stops enforcing a limit is much worse than one that refuses to boot, and this never does that.

On the "does default behavior regress" question I have better evidence than the unit tests. The Serve A/B lane built base and head, drove a fixed endpoint set against each, and the only differing field in the entire response diff was activeWorkStaleMs going 6 → 5 — timing jitter. Combined with argv being unchanged and admissionEnforced: false now asserted under the default, observe users are provably unaffected. That matters more than any amount of reading, because it is the population that carries the risk here.

What is left, and none of it blocks:

  • The hardcoded getTranslator('en') in the session provider leaks further than the label I flagged last time. The same English string reaches a toast via the connection.error re-report path, where it is re-wrapped as a plain Error — so the recognizer does not match it and the localized message this PR added gets bypassed on exactly the automatic-load path it targets. Still cosmetic, still accurate content, but it is a real gap in the localization rather than a missing comment.
  • The eight duplicated capacity branches in McpManagerPage.tsx want a helper.
  • No CI lane executes admit against a real daemon. The verify job dispatched alongside this re-run is the thing that would settle it; its report should land separately. Read it before merge if it arrives, and re-trigger it if it does not — PR2 builds directly on this path.

Verdict is approve — but the approval is not recorded yet, and I want that to be unmissable rather than silently wrong. Two things stand in the way, and neither is about this diff:

  1. The previous pass approved in principle and deferred the actual vote to CI; that deferred approval never got posted, so this commit has been sitting with no bot approval behind it.
  2. This run tried to post it directly — CI on 41a09ac is complete and fully green, nothing pending, guardrails clear — and GitHub rejected the vote with 422 User can only have one pending review per pull request. An empty pending review from the bot (id 5206452871, no body, no inline comments) is holding the one slot this account gets on this PR. It belongs to the 🧐 Qwen Pull Request Review lane, whose run (34937172848) has been pending since 06:29 UTC with zero jobs created, so nothing is actively filling it.

I did not delete or submit that review. It is another lane's container, clearing it is irreversible, and a stuck sibling workflow is not a reason to destroy its state — that call belongs to a maintainer, not to this gate. So the PR currently reads as 1 of 2 required approvals, with the triage verdict in favour.

To land the vote: cancel or re-run 34937172848 (or delete the empty pending review 5206452871), then re-trigger @qwen-code /triage. The head has not moved and nothing in this assessment changes, so the re-run should approve against 41a09ac immediately.

中文说明

Confidence: 4/5 —— 扎实,而且最要命的那部分我没能攻破。没到 5 分的原因是两处外观层面的缺口,以及没有任何 CI 通道以 admit 驱动过真实 daemon,所以行为内核目前仍依赖进程内测试加你的本地运行。

退一步看整体。我自己对这个需求的方案是:在 bridge 里加计数器加互斥锁,spawn 前检查,再配一个新错误类型——一个小型的平行子系统。你交付的版本复用了 ProcessRegistry 里已有的预留机制和 ChildHeapPolicy 里已建模的名额数,所以半年后再读,这看起来像是已有 policy 上多了一个模式,而不是一个子系统。这才是我真正在意的检验标准,它通过了。

计数是准入代码里最容易出错的部分,所以我带着"一定有个差一错误"的预期去看,结果没找到。把调用者自己的预留计入、并以 > 作为拒绝条件,是同一个决定的两半,且恰好抵消:建模一个名额时,第一次 spawn 获准,第二次被拒。拒绝发生在 catch 会取消预留并重新抛出的那个 try 内,且在任何 spawn() 之前,所以名额和子进程都不会泄漏。计数也确实是 daemon 级的——我核了全部四个生产工厂调用点而不是想当然,它们共享同一个 registry 和同一个 policy 实例,且 ?? 短路使第二个 policy 不可达。

fail-closed 的纪律是我本来会要求、结果不用开口就有的:零名额预算、拿到准入 policy 却没有 registry、admit 配合注入 bridge、以及 createServeApp 缺少准入接线,全部在启动期拒绝,而不是悄悄降级成 observe。一个静默停止执行限额的 daemon 远比一个拒绝启动的 daemon 糟糕,而这里不会发生那种情况。

关于"默认行为是否回归",我拿到了比单元测试更有力的证据。Serve A/B 通道分别构建 base 与 head、对同一组固定端点驱动并比对,整个响应 diff 中唯一不同的字段是 activeWorkStaleMs 从 6 变 5——计时抖动。再加上 argv 未变、且默认模式下现在断言了 admissionEnforced: falseobserve 用户可证明不受影响。这比读多少代码都重要,因为承担风险的正是一群 observe 用户。

剩下的问题,都不阻塞:

  • session provider 里硬编码的 getTranslator('en') 泄漏范围比我上次指出的标签更宽。同一段英文还会经 connection.error 再上报路径进入 toast,而在那里它被重新包装成普通 Error——于是识别函数匹配不上,本 PR 新增的本地化消息在它本要服务的那条自动加载路径上被绕过了。仍属外观问题、内容仍准确,但这是本地化里一个真实的缺口,而不只是少了一行注释。
  • McpManagerPage.tsx 里八处重复的容量分支需要一个 helper。
  • 没有任何 CI 通道以 admit 对真实 daemon 执行过。与本次 re-run 一同派发的 verify job 正是能落定这件事的东西,其报告应会单独发布。若它到达,请在合并前读一遍;若没有,请重新触发——PR2 直接构建在这条路径上。

结论是批准——但这一票目前尚未记录成功,我希望这一点显眼,而不是悄悄地错着。 有两个障碍,都与本 diff 无关:

  1. 上一轮在结论上批准、但把实际投票推迟给了 CI;那个被推迟的批准始终没有发出去,所以该 commit 一直没有机器人的批准票。
  2. 本次尝试直接投票——41a09ac 的 CI 已完整跑完且全绿、无待决项、各项守卫检查均通过——但 GitHub 以 422 User can only have one pending review per pull request 拒绝。一个来自机器人的 pending review(id 5206452871,无正文、无行内评论)占用了该账号在本 PR 上唯一的名额。它属于 🧐 Qwen Pull Request Review 通道,其 run(34937172848)自 UTC 06:29 起一直是 pending,且没有创建任何 job,所以并没有任何东西在往里填内容。

我没有删除、也没有提交那个 review。它是另一个通道的容器,清除是不可逆的,而一个卡住的兄弟 workflow 不构成销毁其状态的理由——这个判断属于维护者,不属于本门禁。因此该 PR 目前显示为 2 个必需批准中的 1 个,而 triage 的结论是支持的。

要让这一票落地:取消或重跑 34937172848(或删除空的 pending review 5206452871),然后重新触发 @qwen-code /triage。head 没有变动,本结论也不会改变,因此 re-run 应当会立即对 41a09ac 投出批准。

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

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

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 73 passed · 0 failed · 73 total

Flakiness gate: ⚠️ timeout — only 3 of 5 rounds fit the 15-minute budget; the completed rounds agreed

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

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

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

抖动门:⚠️ timeout — only 3 of 5 rounds fit the 15-minute budget; the completed rounds agreed

Verification report

PR #11911feat(serve): add budget-based ACP child admission

Verdict: findings — 73/73 scripted assertions passed, 0 unexpected failures. The central claim is proven load-bearing by an A/B against the base build (13 cells flip). The findings are non-blocking: one user-visible i18n inconsistency introduced by this PR, one startup guard the description claims but no test pins, and one stale doc comment. Verified head: 41a09ac66cefe13fb10bc1a5e9535c5c7716f045 (git rev-parse HEAD^2); base control: e4a6ccd44980d95b363a6712fd1aecc43d80e028 (HEAD^1).

中文摘要
  • 结论(verdict)findings。73 项脚本化断言全部通过,0 项意外失败。核心改动经 A/B 证明是 load-bearing 的(head 26/26,base 对照 13/26,13 个单元翻转),但存在 3 个非阻塞问题。
  • A/B 结论--child-heap-mode admit 确实在真实 spawn 路径上强制并发子进程上限。head 在 1 名额预算下拒绝第 2 个并发 ACP 子进程并返回 acp_child_capacity_exhaustedmaxConcurrentChildren=1committedAcpChildren=1、无 Retry-After);base 在完全相同的输入下照常启动两个真实子进程。释放名额后主动重试成功;终止中的子进程仍计数(见 cell10)。observe/off 两种模式在 head 上仍不强制。子进程堆参数(--max-old-space-size=16384 --expose-gc)在 admit 与 observe 下完全一致。
  • findings:(1) DaemonSessionProvider.tsx 硬编码 getTranslator('en'),本 PR 新增的 zh-CN 文案在该连接错误界面上不可达,中文用户会看到英文横幅而同一事件的 toast 是中文;(2) run-qwen-serve.ts 中 "admit + 注入 bridge 启动即拒绝" 的守卫没有任何测试 pin 住(变异后 503 个测试全绿),而 PR 描述声称该行为;(3) child-heap-policy.tsrefusals 的文档仍按 "observe-only 指标" 描述,在 admit 下这些是真实拒绝,文档已过时。
  • 未覆盖范围:真实 daemon 端到端 REST 503(改用真实 Express + 真实 sendBridgeError/toRpcError 的 wire-oracle 替代);浏览器/Chromium 验证;daemon 日志可观测性(探针仪器失效,见 Not covered);per-commit 归因(浅克隆只可达 1/3 个 commit);Windows/macOS。

Central claim and A/B

Central claim: qwen serve --child-heap-mode admit enforces the modeled concurrent managed-ACP-child limit at the real spawn path, refusing the start that exceeds it with a recognizable capacity error, while observe (default) still allows it.

Harness: ab-spawn-admission.mjs — real compiled createSpawnChannelFactory, real ProcessRegistry, real createChildHeapPolicy, real resolveDaemonMemoryBudget, and real child processes (a fake ACP child via QWEN_CLI_ENTRY, which the scrub list does not strip). The identical harness ran against head dist and against a base worktree (tmp/base-tree = HEAD^1) whose dist was verified pre-change (exact "Observation only" hunk present, ChildHeapMode = 'off' | 'observe', zero AcpChildCapacityExceededError occurrences) and whose import closure is self-contained (no @qwen-code/* imports), so no workspace symlink could leak head code into the control. Realpath check: node_modules/@qwen-code/acp-bridge → head tree; base harness imports tmp/base-tree/... by absolute path.

Witness: evidence/01-ab-head-vs-base-spawn-admission.png (the table below as it printed), evidence/04-base-control-arm-admits-beyond-limit.png.

Cell Oracle HEAD BASE
1-slot budget models exactly 1 child policy.snapshot().maxConcurrentChildren PASS (1) PASS (1)
1st spawn succeeds (real process) factory resolves, live pid PASS PASS
2nd concurrent spawn refused throws / resolves PASS (refused) FAIL (spawned)
refusal is AcpChildCapacityExceededError, code acp_child_capacity_exhausted error identity + .code PASS FAIL
maxConcurrentChildren=1, committedAcpChildren=1 error fields PASS FAIL
refused reservation released (no leak) committedProcessCount back to 1 PASS FAIL (2)
refusals counted exactly once policy.snapshot().refusals PASS PASS
spawn during teardown still refused (terminating child counts) throws PASS FAIL
retry after release succeeds factory resolves PASS PASS
observe / off: both spawns succeed 2 live processes PASS PASS
3-slot budget: 3 spawn, 4th refused error fields max=3 committed=3 PASS FAIL
registry really holds N active children activeProcessCount PASS FAIL (4)
zero-slot admit config rejected at construction TypeError PASS FAIL (constructed)
admit policy without explicit registry rejected TypeError PASS FAIL (constructed)
child heap args identical admit vs observe child's own process.execArgv PASS PASS

Totals: HEAD 26/26, BASE 13/26 — 13 cells flip, all in the enforcement direction. The base arm's 13 reds are the control's expected pre-PR behaviour; they are encoded as 7 passing assertions in control-base.mjs (7/7), including "base really has 2 live child processes in the registry (over-admission is real, not a stub)". cell11 passes on both arms by design — it is the no-regression cell for "admit keeps legacy heap flags".

Secondary claim 1 — reservations/terminating count, registration/reuse do not. Proven for the reservation and teardown halves (cells 2 and 10). Registration/reuse consume no slot by construction: no spawn ⇒ no reserve(); the census below shows spawnChannel.ts:501 is the only --acp spawn site.

Secondary claim 2 — recognizable capacity error on REST and ACP, no Retry-After, status distinguishes enforcement. Proven by wire-contract.mjs (26/26) and consumer-contract.ts (9/9): real Express + real sendBridgeError/toRpcError over a real loopback socket. Witness: evidence/02-wire-contract-rest-rpc-status-contract.png.

  • REST direct & wrapped: 503, code/errorKind = acp_child_capacity_exhausted, no Retry-After, counts on the body.
  • Standalone rollback + capacity: 503 (not the code-derived 500), capacity object on the wire, retryable: true but Retry-After suppressed, original code preserved.
  • RPC/SSE: data.errorKind/data.httpStatus/data.capacity carry the same metadata; the Web Shell predicate isAcpChildCapacityError recognises all six real producer shapes and rejects both non-capacity controls.
  • Status: memory.enforced stays false under admit (heap ceilings still not applied) while childHeap.admissionEnforced is true under admit, false under observe, and defaults to false when omitted.
  • Regression controls: retryable non-capacity standalone still 500 + Retry-After: 5; generic runtime-init still 503 runtime_initialization_failed + Retry-After: 5.

Secondary claim 3 — Web Shell stops automatic capacity retries and preserves drafts. Covered by the PR's own component/provider tests (1370/1370 in the changed files, including "stops automatic load retries on capacity rejection" for both body shapes and the /goal draft path). Not independently re-driven in a browser — see Not covered.

Bypass census (the "accepting end" of the gate). spawnChannel.ts:501 is the only --acp spawn site. Four production createSpawnChannelFactory call sites exist; the three daemon bridges (run-qwen-serve.ts:5274/6286/6946) all receive the shared processRegistry + admit policy, and createServeApp's guard throws before a bridge could fall back to the ungated defaultSpawnChannelFactory under admit. No production path bypasses admission.

Findings

F1 — Suggestion: the zh-CN capacity message is unreachable from the connection-error surface

packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:4012 hardcodes getTranslator('en')('daemon.capacity.exhausted'), and the resulting message is stored into user-visible connection.error. This PR adds a zh-CN string for that key (i18n.tsx) and uses the locale-aware t() at the other two surfaces (App.tsx toast + notice handler, McpManagerPage.tsx). A zh-CN user therefore sees an English connection-error banner for the same capacity rejection whose toast is Chinese.

Reproduce:

grep -n "getTranslator('en')" packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx
# 4012:            ? getTranslator('en')('daemon.capacity.exhausted')

The hardcoded 'en' is a deliberate guard — the PR's own test is named "shows a readable capacity reason on automatic load without an i18n provider", and the i18n context default is { language: 'en', t: (key) => key }, so useI18n().t outside a provider would leak the raw key. But the language is safe to read: the same context default supplies language: 'en', and in production DaemonSessionProvider is always rendered inside App's I18nProvider (App.tsx:17749). So getTranslator(useI18n().language) (captured in a ref for the async SSE loop) keeps the provider-less fallback and honours the active locale. Not measured in a browser; classified from source + the provider nesting census.

F2 — Suggestion: the admit + injected-bridge startup guard is pinned by no test

The description states "an injected/unwired admitting bridge are rejected at startup". The unwired half is tested (server.test.ts: "rejects unwired admission before creating the app"). The injected-bridge half (run-qwen-serve.ts:3829) has no test: disabling the guard left 503/503 tests green across run-qwen-serve.test.ts + serve.test.ts (mutation M10). House rule classifies a missing test for changed behaviour as a Suggestion. The guard's runtime behaviour was therefore not verified by this round either — it is listed under Not covered.

F3 — Nit: refusals documentation is stale under admit

packages/acp-bridge/src/child-heap-policy.ts still documents refusals as "Spawns that would have been refused" and calls the full-occupancy channel-swap case a "metric artifact". Under admit those are real, user-facing rejections (proven by cell10: a spawn during teardown of the only child is refused). The PR description acknowledges the tradeoff; the field's own doc does not.

Completeness (not merge conditions) — mutation survivors, all classified

Witness: evidence/03-mutation-matrix-killed-vs-survived.png. 23 mutation runs; every load-bearing guard is pinned (M1 central hunk, M2 zero-slot guard, M3 shared-registry guard, M4 Retry-After suppression, M5 REST branch, M6 RPC branch, M8 standalone propagation, M9-2 coordinator clause 2, and the combination row M9-all proving the coordinator clause set is load-bearing). Survivors:

  • M9-1 / M9-3 / M9-13 (coordinator clauses 1 & 3, alone and together): redundant defence. Both REST (error-response.ts:438) and RPC (dispatch.ts:661) unwrap WorkspaceRuntimeInitializationError.cause one level, so a capacity error wrapped the way those clauses would wrap it still maps to 503 + capacity code + no Retry-After. Proven by the observability-probe.mjs cell "wrapped capacity still maps to 503 + capacity code", not by reading the code.
  • M7b (admissionEnforced default flipped to true): unreachable default. createServeApp always passes an explicit boolean (deps.managedChildProcesses?.policy.snapshot().mode === 'admit'), so the parameter default never applies in production. The payload is pinned both ways (M7c killed by run-qwen-serve.test.ts .toBe(false); M7d killed by daemon-status.test.ts admissionEnforced: true).
  • M7 / M7-ctl (rounds 1–2): superseded / invalid target — the same-file-control rule showed the pinning tests live in a different file than the one first chosen; rounds 3–4 re-ran against both files.

Same-file positive control: M7d turned daemon-status.test.ts red (expected { enforced: false, … } to match object …), and M1/M9-2 failure text was captured (AssertionError: expected WorkspaceRuntimeInitializationError: … to be AcpChildCapacityExceededError: …), so the harness demonstrably makes these suites fail.

Not covered

  • Full-daemon end-to-end REST 503. Not booted; the wire contract was proven at the seam instead (real Express + real sendBridgeError/toRpcError + real error classes over a real loopback socket). This reproduces the wire shapes the daemon emits, not the route-to-spawn traversal inside a live daemon.
  • Daemon-log observability of capacity rejections. observability-probe.mjs could not measure it: the case that unconditionally calls recordExpectedBridgeError (direct capacity) emitted nothing to stderr in a bare Express harness, so emitDaemonLog has no sink there. A dead instrument supports no conclusion either way; the apparent asymmetry (if (status === 500) recordExpectedBridgeError(...)) is therefore reported as unmeasured, not as a finding.
  • Browser/Chromium verification of the Web Shell behaviour (capacity toast, draft retention, single create request). Only the component/provider tests were run.
  • The admit + injected-bridge guard's runtime behaviour (F2) — only its absence of test coverage was proven.
  • Per-commit attribution. The checkout is shallow: git rev-list HEAD^1..HEAD^2 returns 1 while $QWEN_VERIFY_CONTEXT lists 3 commits. All evidence is against the aggregate HEAD^1..HEAD diff.
  • CLI-parser base arm. Base's fast-path.ts was not executed (its TS import closure fails to resolve in a bare worktree); base rejection is evidenced statically (base source if (read.value !== 'off' && read.value !== 'observe') return { kind: 'fallback' }, base serve.ts choices: ['off','observe'], head compiled choices: ['off','observe','admit']). Head acceptance was executed (2/2).
  • Base tsc --build failed on pre-existing packages/core type errors unrelated to this PR; the base acp-bridge dist emitted before the failure was verified pre-change and complete for the import closure used, and is cited as such.
  • Windows/macOS behaviour, real low-memory hosts, natural keep-alive expiry (all also out of scope per the PR).

Methodology

Environment: CI verify container (node:22-bookworm), merge-ref checkout (HEAD = merge, HEAD^1 = base tip, HEAD^2 = PR head), npm ci + npm run build pre-run at HEAD. Harnesses import the compiled dist/ of the tree named on the command line; the base control is a git worktree at HEAD^1 under tmp/, built with npm run build -w packages/acp-bridge (its tsc --build failed on unrelated core errors but emitted the four needed modules, verified pre-change by exact-hunk grep and by ChildHeapMode in the emitted .d.ts), imported by absolute path with a realpath assertion that no @qwen-code/* symlink leaks head code. ab-spawn-admission.mjs spawns real Node children through the real factory; wire-contract.mjs / observability-probe.mjs mount the real error mappers on real Express over 127.0.0.1; consumer-contract.ts (tsx) feeds the dumped real wire bytes into the real Web Shell predicate and the real DaemonHttpError. Gates: acp-bridge targeted files (child-heap-policy, spawnChannel, process-registry) 97/97; CLI serve changed files 2162/2162; web-shell changed files 1370/1370; npm run typecheck exit 0. Mutation matrix: 23 runs, each reverting one guard in real source with byte-exact restore (git status --porcelain clean afterwards). Assertion counts in assertions.json cover only the six harnesses' scripted checks (26+7+26+9+2+3); the base arm's 13 reds are the control's expected behaviour and are counted as the 7 passing control-base.mjs assertions, and the mutation matrix is reported separately as completeness. Raw logs: logs-02-wire-contract.txt, logs-03-mutation.txt, logs-04-base-control.txt, mutation-matrix*.json, wire-bodies.json in this directory.

Flakiness gate log

rounds=5 files=14 skipped=0
file packages/acp-bridge/src/child-heap-policy.test.ts: (cd packages/acp-bridge) npx --no-install vitest run ./src/child-heap-policy.test.ts
file packages/acp-bridge/src/spawnChannel.test.ts: (cd packages/acp-bridge) npx --no-install vitest run ./src/spawnChannel.test.ts
file packages/cli/src/commands/serve.test.ts: (cd packages/cli) npx --no-install vitest run ./src/commands/serve.test.ts
file packages/cli/src/serve/acp-http/dispatch-error.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/acp-http/dispatch-error.test.ts
file packages/cli/src/serve/conversations/standalone-session-service.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/conversations/standalone-session-service.test.ts
file packages/cli/src/serve/daemon-status.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/daemon-status.test.ts
file packages/cli/src/serve/fast-path.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/fast-path.test.ts
file packages/cli/src/serve/run-qwen-serve.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/run-qwen-serve.test.ts
file packages/cli/src/serve/server.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/server.test.ts
file packages/cli/src/serve/server/error-response.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/server/error-response.test.ts
file packages/cli/src/serve/workspace-runtime-coordinator.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/workspace-runtime-coordinator.test.ts
file packages/web-shell/client/App.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/App.test.tsx
file packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/daemon/session/DaemonSessionProvider.test.tsx
file packages/web-shell/client/daemon/session/httpErrors.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/daemon/session/httpErrors.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/acp-bridge/src/child-heap-policy.test.ts: PPPP
  packages/acp-bridge/src/spawnChannel.test.ts: PPPP
  packages/cli/src/commands/serve.test.ts: PPPP
  packages/cli/src/serve/acp-http/dispatch-error.test.ts: PPPP
  packages/cli/src/serve/conversations/standalone-session-service.test.ts: PPP
  packages/cli/src/serve/daemon-status.test.ts: PPP
  packages/cli/src/serve/fast-path.test.ts: PPP
  packages/cli/src/serve/run-qwen-serve.test.ts: PPP
  packages/cli/src/serve/server.test.ts: PPP
  packages/cli/src/serve/server/error-response.test.ts: PPP
  packages/cli/src/serve/workspace-runtime-coordinator.test.ts: PPP
  packages/web-shell/client/App.test.tsx: PPP
  packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: PPP
  packages/web-shell/client/daemon/session/httpErrors.test.ts: PPP

verdict: timeout
summary: only 3 of 5 rounds fit the 15-minute budget; the completed rounds agreed

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/acp-bridge/src/child-heap-policy.test.ts: P (exit 0)
round 1 · packages/acp-bridge/src/spawnChannel.test.ts: P (exit 0)
round 1 · packages/cli/src/commands/serve.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/acp-http/dispatch-error.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/conversations/standalone-session-service.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/daemon-status.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/fast-path.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/run-qwen-serve.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/server/error-response.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/workspace-runtime-coordinator.test.ts: P (exit 0)
round 1 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/daemon/session/httpErrors.test.ts: P (exit 0)
round 2 · packages/acp-bridge/src/child-heap-policy.test.ts: P (exit 0)
round 2 · packages/acp-bridge/src/spawnChannel.test.ts: P (exit 0)
round 2 · packages/cli/src/commands/serve.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/acp-http/dispatch-error.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/conversations/standalone-session-service.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/daemon-status.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/fast-path.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/run-qwen-serve.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/server/error-response.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/workspace-runtime-coordinator.test.ts: P (exit 0)
round 2 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/daemon/session/httpErrors.test.ts: P (exit 0)
round 3 · packages/acp-bridge/src/child-heap-policy.test.ts: P (exit 0)
round 3 · packages/acp-bridge/src/spawnChannel.test.ts: P (exit 0)
round 3 · packages/cli/src/commands/serve.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/acp-http/dispatch-error.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/conversations/standalone-session-service.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/daemon-status.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/fast-path.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/run-qwen-serve.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/server/error-response.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/workspace-runtime-coordinator.test.ts: P (exit 0)
round 3 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/daemon/session/DaemonSessionProvider.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/daemon/session/httpErrors.test.ts: P (exit 0)
round 4 · packages/acp-bridge/src/child-heap-policy.test.ts: P (exit 0)
round 4 · packages/acp-bridge/src/spawnChannel.test.ts: P (exit 0)
round 4 · packages/cli/src/commands/serve.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/acp-http/dispatch-error.test.ts: P (exit 0)

Evidence images

01-ab-head-vs-base-spawn-admission

02-wire-contract-rest-rpc-status-contract

03-mutation-matrix-killed-vs-survived

04-base-control-arm-admits-beyond-limit

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

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

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

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

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

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review findings

Scope: Production source files only. Docs, test files reviewed structurally but not line-by-line.
Environment: Static analysis only — no local build available; rung-2 execution not performed.


R1-1 · Minor · DaemonSessionProvider.tsx:4012

getTranslator('en') hardcodes English regardless of the user's language setting. The Chinese translation for daemon.capacity.exhausted was added in i18n.tsx but is never reached from this path.

The leak is wider than the call site: connection.error is set to the (English) string, ChatPane.tsx renders it as {t('splitView.paneConnectionError')}: {connection.error}, and App.tsx re-reports it via onError(new Error(connection.error)) — a plain Error, not a DaemonHttpError, so isAcpChildCapacityError returns false there and the localized path added in this PR is bypassed on the automatic-load path it targets.

Content is accurate; this is cosmetic only. If the provider genuinely cannot access the I18nProvider context, a short comment explaining why would prevent future readers from treating it as an oversight. Every other component that needs this key derives the language (App from selectedLanguage, McpManagerPage from useI18n()).


Coverage

Checked:

  • Admission slot arithmetic: reserve() increments count before decide(), refusal condition is > maxConcurrentChildren — first spawn sees count=1 with limit=1 and is admitted; second sees count=2 and is refused. No off-by-one. ✓
  • Reservation leak on refusal: throw inside the try whose catch is reservation.cancel(); throw — cancelled before any spawn(), no orphaned child. committedAcpChildren reported as count-1 (pre-cancel value). ✓
  • Daemon-wide count: four production createSpawnChannelFactory call sites all pass the same processRegistry and childHeapPolicy instance; admissionPolicy ?? createChildHeapPolicy(...) short-circuits so no second policy is ever built. ✓
  • Fail-closed: four separate construction-time guards (zero-slot budget, admitting policy without registry, admit + injected bridge, createServeApp without admitting wiring). None silently degrades to observe. ✓
  • Error survival: capacity branch in sendBridgeError sits before the generic WorkspaceRuntimeInitializationError branch and unwraps .cause; toRpcError mirrors the same pattern. Raw rethrow from coordinator bypasses no handler — the sole production reader already unwraps .cause. ✓
  • Retry-After suppressed on capacity (err.retryable && !err.capacity); auto-reconnect blocked by capacityRejected condition. ✓
  • observe mode unaffected: decide() still called once per spawn; admission branch only fires when snapshot says admit; argv unchanged. Consistent with A/B lane showing only timing jitter. ✓
  • isAcpChildCapacityError: five-shape recognizer covers all live REST and RPC body shapes; tests enumerate each case and the non-capacity 503s that must not match. ✓
  • SDK types: admissionEnforced and committedAcpChildren added as optional (additive for older daemons). ✓

Not covered:

  • Execution rung 2 not performed (no local build). The in-process unit suite (462 lines across 14 files, including the cross-factory slot-release case) provides strong static evidence; no CI lane drives a real daemon with --child-heap-mode admit. This is a noted gap but not an approval blocker given the author's detailed macOS verification and the A/B no-regression signal for the default path.
  • Windows and Linux runtime behavior — not tested on either side.

No blocking findings. Approval blockers: none.

Reviewed with AI assistance.

error instanceof Error ? error.message : String(error);
const capacityRejected = isAcpChildCapacityError(error);
const message = capacityRejected
? getTranslator('en')('daemon.capacity.exhausted')

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.

Minor: getTranslator('en') hardcodes English regardless of user language. The Chinese translation for daemon.capacity.exhausted was added in i18n.tsx but is never reached via this path.

The leak is wider than this line: connection.error is set to the English string, re-rendered verbatim in ChatPane.tsx, and re-wrapped as a plain Error in App.tsx — so isAcpChildCapacityError returns false on that re-report path and the localized message this PR added is bypassed on exactly the automatic-load path it targets.

Content is accurate; this is cosmetic. If the provider genuinely cannot read the I18nProvider context, a one-line comment would save the next reader from treating it as an oversight.

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM — no blocking issues at 41a09ac; first human review pass. The mechanism is sound where it matters: reserve() → decide(committedProcessCount) is synchronous with no await between, so racing admissions serialize through the shared registry; the budget source is the existing cgroup-aware resolver with consistent MB units; rejection is a typed 503 acp_child_capacity_exhausted with deliberate Retry-After suppression; reservations are cancelled on refusal and the ledger release on child death reuses the pre-existing registry teardown.

Two P2s for follow-ups: (1) a terminating child still counts until registry release, so at the modeled limit a workspace whose child just crashed gets 503 on its recovery spawn during the teardown window — worth a grace slot; (2) the budget and slot count are resolved once at daemon startup and never re-read, so a cgroup limit change on a long-lived daemon silently widens protection until restart — the docs added here should say limits changes require a restart.

@doudouOUC
doudouOUC added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit 88347cc Sep 15, 2026
73 checks passed
@doudouOUC
doudouOUC deleted the codex/acp-capacity-admission-pr1 branch September 15, 2026 08:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants