Skip to content

refactor(serve): model a per-child heap partition of the daemon budget - #8508

Merged
doudouOUC merged 6 commits into
agent/daemon-memory-observefrom
agent/child-heap-admission
Aug 4, 2026
Merged

refactor(serve): model a per-child heap partition of the daemon budget#8508
doudouOUC merged 6 commits into
agent/daemon-memory-observefrom
agent/child-heap-admission

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #8423. Base is agent/daemon-memory-observe; GitHub retargets to main when that merges.

What this PR does

Models a per-child heap partition of the daemon's memory budget and reports it. It applies nothing — no child is sized from the budget, no spawn is refused.

--child-heap-mode is off | observe, default observe. Status gains limits.memory.childHeap: maxConcurrentChildren, perChildCeilingMb, and refusals (spawns that would have exceeded the modeled limit).

It also fixes a zero-pool defect introduced by an earlier revision of this PR: forcing at least one admissible child meant a 512 MB host — where the root reserve consumes the entire 256 MB budget, leaving a pool of 0 — modeled a ceiling of 0. --max-old-space-size=0 is V8's default heap, roughly 4 GB, not a zero ceiling. A pool that cannot cover one child at the 512 MB floor now reports maxConcurrentChildren: 0 and perChildCeilingMb: null, and the test that had enshrined the old behaviour is inverted.

Why it's needed

getAcpMemoryArgs() computes one ceiling from host memory and hands the same value to every child. The daemon runs one child per workspace and registers up to 25, so a 32 GB host authorises 25 × 16 GB = 400 GB of child heap. That is the defect #8182 records. This PR does not close it — it publishes the partition that would close it, so the partition can be judged before anything depends on it.

Why it stops at modeling. The earlier revision of this PR applied the partition and refused spawns. Review established two things that took that apart, both correct:

The per-spawn share was not an aggregate bound. Sizing each child by the count live at its spawn bounds the child count but not the memory — V8 cannot lower a running child's ceiling, so grants accumulate as P + P/2 + P/3 + … = P × H(n). Reproduced: 9557 MB authorised against a 3687 MB pool at seven children on 8 GB; 61355 MB against 15360 MB on 32 GB. That is fixed — the model is now one constant ceiling for every child, with admission capped so the total stays inside the pool by construction, verified as an invariant across four host sizes.

The refusal counter could not tell an operator when enforcing was safe. While observing, children run on the host-derived ceiling — 16384 MB on a 32 GB host — so a workload needing 2 GB of old space is healthy with refusals: 0 and OOMs the instant a 614 MB partition is applied. The counter measures admission pressure, not ceiling adequacy.

That second point is what removed enforce. Its inclusion rested on "observe first, then enforce", and that path does not exist yet: answering it needs peak old-space per child (not rss, which is what the child reports today, and not heapUsed, which includes new space) compared against the modeled ceiling. That is a measurement chain of its own, and the enforcing mode ships with it. Shipping a switch with no safe way to decide when to turn it on is worse than shipping no switch.

Since nothing applies the partition, the machinery that existed only to apply it went with it rather than shipping unreachable: getAcpMemoryArgs(explicitMb?), ChildHeapPoolExhaustedError and both transport mappings, and limits.memory.enforced reverts to the required literal false. The spawn path is untouched; the factory asks the policy what it would decide purely so the count is real. Net −310 lines against the previous revision.

What an operator gets instead. perChildCeilingMb and maxConcurrentChildren are published so the partition can be judged against a known workload — the substitute for a counter that cannot judge it. An 8 GB host models 7 children at 526 MB; a 32 GB host models 25 at 614 MB, both pinned in tests since they are the numbers one plans against. Every claim that refusals: 0 means the partition is safe to apply is gone from the flag help, the three operator docs, the protocol doc, and the design doc.

Reviewer Test Plan

How to verify

1. The modeled partition is reported, and it fits inside the pool. Boot a daemon and read the new block:

curl -s 'http://127.0.0.1:4170/daemon/status' | jq '.limits.memory.childHeap'

On an 8 GB host expect {"mode":"observe","maxConcurrentChildren":7,"perChildCeilingMb":526,"refusals":0}; on 32 GB, 25 at 614. The invariant that makes it an aggregate bound holds on any host:

curl -s 'http://127.0.0.1:4170/daemon/status' | \
  jq '.limits.memory | (.childHeap.maxConcurrentChildren * .childHeap.perChildCeilingMb) <= .modeled.childPoolMb'
# true

2. Nothing is applied — this is the check that matters. Open a session so a qwen --acp child spawns, then read its argv under each mode:

ps -o args= -p "$(pgrep -f 'qwen --acp' | head -1)" | tr ' ' '\n' | grep max-old-space-size

The value must be the host-derived ceiling (e.g. 16384 on a 32 GB host) and must be byte-identical under --child-heap-mode observe and --child-heap-mode off. In particular it must not equal perChildCeilingMb. If those two runs differ, this PR's central claim is false.

3. limits.memory.enforced is still the literal false. The previous revision widened it to boolean; that is reverted, and clients may keep relying on it:

curl -s 'http://127.0.0.1:4170/daemon/status' | jq '.limits.memory.enforced'
# false

4. The enforcing mode is genuinely absent, not merely defaulted off. Both the yargs path and the fast path must reject it:

qwen serve --child-heap-mode enforce
# error: Invalid values: Argument: child-heap-mode, Given: "enforce", Choices: "off", "observe"

Note for the triage bot: the earlier review asked for steps to trip a ChildHeapPoolExhaustedError under enforce. That review read the pre-revision body — enforce and that error class were both deleted in this revision, for the reason given under Why it's needed. Step 4 is the replacement: it verifies they are gone.

5. Unit suites.

npx vitest run packages/acp-bridge/src/child-heap-policy.test.ts
npx vitest run packages/cli/src/serve/daemon-status.test.ts

Evidence (Before & After)

N/A — no user-visible or TUI change. The observable difference is the /daemon/status payload:

  "limits": { "memory": {
      "enforced": false,
+     "childHeap": { "mode": "observe", "maxConcurrentChildren": 7, "perChildCeilingMb": 526, "refusals": 0 },
      "modeled": { "childPoolMb": 3687, ... }
  } }

Child argv is unchanged in both directions — that is the point, and step 2 above is how a reviewer confirms it.

Tested on

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

Locally on Linux: 48 acp-bridge + 175 cli tests, prettier, eslint, and tsc on acp-bridge, cli, and sdk-typescript. The invariant (maxConcurrentChildren × perChildCeilingMb ≤ childPoolMb) is tested across 2/8/32/256 GB hosts; observe is asserted to leave spawn argv byte-identical to a factory with no policy; enforce is asserted to be rejected by both yargs and the fast path. macOS and Windows are left to CI — the change is arithmetic and status plumbing with no platform-specific paths.

Environment (optional)

npm run dev daemon on Linux; unit tests only for the assertions above.

Risk & Scope

  • Main risk or tradeoff: the published numbers invite a reading they cannot support — an operator seeing refusals: 0 may conclude the partition is safe to enforce. It is not, for the reason in Why it's needed, and that caveat is now stated in the flag help, all three operator docs, and the protocol doc. The tradeoff accepted here is shipping a model that constrains nothing: bug(serve): daemon authorises each ACP child 50% of host memory, never divided by child count #8182's 400 GB overcommit stays open until the enforcing mode lands with its measurement.
  • Not validated / out of scope: applying the partition; the peak old-space measurement the enforcing mode depends on; channel-worker and MCP-descendant memory, which have no reporting path; and RSS bounds of any kind, since --max-old-space-size covers only V8 old space and never Buffers, native allocations, or descendants.
  • Breaking changes / migration notes: none. limits.memory.childHeap is additive, and limits.memory.enforced remains the required literal false — the earlier revision of this PR widened it to boolean, and that widening is reverted here, so no client contract changes. --child-heap-mode is a new flag defaulting to observe; enforce is not an accepted value.

Linked Issues

Refs #8182. Stacked on #8423.

中文说明

叠在 #8423 之上。 base 是 agent/daemon-memory-observe;该 PR 合并后 GitHub 会自动改指向 main

这个 PR 做了什么

对 daemon 内存预算按子进程建模出一份堆分区,并把它报告出来。它不应用任何东西——没有子进程按预算调整大小,也没有派生被拒绝。

--child-heap-mode 取值 off | observe,默认 observe。状态新增 limits.memory.childHeapmaxConcurrentChildrenperChildCeilingMb,以及 refusals(本会超出建模上限的派生次数)。

同时修复了本 PR 早前版本引入的「零池」缺陷:强制至少允许一个子进程,意味着在 512 MB 主机上——root 预留吃掉了全部 256 MB 预算,池为 0——会建模出上限 0。而 --max-old-space-size=0 表示 V8 的默认堆,约 4 GB,并非零上限。现在,连一个 512 MB 下限子进程都容纳不下的池会报告 maxConcurrentChildren: 0perChildCeilingMb: null,那个把旧行为固化成预期的测试也已被反转。

为什么需要它

getAcpMemoryArgs() 从主机内存算出一个上限,然后把同一个值发给每个子进程。daemon 每个 workspace 跑一个子进程,最多注册 25 个,于是一台 32 GB 主机授权了 25 × 16 GB = 400 GB 的子进程堆。这就是 #8182 记录的缺陷。本 PR 并未关闭它——而是把能关闭它的那份分区先发布出来,好让这份分区在被依赖之前先接受检验。

为什么止步于建模。 本 PR 的早前版本应用了分区并拒绝派生。评审确立了两点,把它拆掉了,且两点都成立:

按派生时刻分摊不构成总量约束。各自派生时刻的存活数给每个子进程定额,约束的是子进程数量而非内存——V8 无法下调运行中子进程的上限,于是授权累加为 P + P/2 + P/3 + … = P × H(n)。已复现:8 GB 主机上七个子进程时,对 3687 MB 的池授权了 9557 MB;32 GB 上是 61355 MB 对 15360 MB。这一点已修正——模型现在是给每个子进程一个恒定上限,并对准入设上限,使总量按构造落在池内,并作为不变量在四种主机规格上验证。

拒绝计数无法告诉运维何时开启强制是安全的。 观察期间子进程跑在主机推导的上限上——32 GB 主机是 16384 MB——所以一个需要 2 GB 老生代的负载在 refusals: 0 下完全健康,而一旦应用 614 MB 的分区就会立刻 OOM。这个计数衡量的是准入压力,不是上限是否够用。

第二点正是移除 enforce 的原因。它的存在依赖「先观察、再强制」,而这条路径尚不存在:要回答它,需要每个子进程的峰值老生代(不是子进程今天上报的 rss,也不是包含新生代的 heapUsed)与建模上限对比。那本身是一条独立的测量链,强制模式将与它一同发布。发布一个无法安全判断何时开启的开关,比不发布这个开关更糟。

既然没有任何东西应用这份分区,那些只为应用它而存在的机制也随之移除,而不是以不可达的形式发布:getAcpMemoryArgs(explicitMb?)ChildHeapPoolExhaustedError 及其两处传输层映射,以及 limits.memory.enforced 回退为必需的字面量 false。派生路径未被改动;工厂询问策略「会做什么决定」,纯粹是为了让计数真实。相对上一版净减 310 行。

运维实际得到什么。 发布 perChildCeilingMbmaxConcurrentChildren,是为了让这份分区能对照已知负载来判断——这是那个无法做判断的计数的替代品。8 GB 主机建模为 7 个子进程、每个 526 MB;32 GB 建模为 25 个、每个 614 MB,两者都在测试中固定,因为这正是做容量规划时依据的数字。所有「refusals: 0 意味着分区可以安全应用」的说法,已从 flag 帮助、三份运维文档、协议文档和设计文档中清除。

评审者测试计划

如何验证

1. 建模分区被报告出来,且落在池内。 启动 daemon 并读取新字段:

curl -s 'http://127.0.0.1:4170/daemon/status' | jq '.limits.memory.childHeap'

8 GB 主机上预期 {"mode":"observe","maxConcurrentChildren":7,"perChildCeilingMb":526,"refusals":0};32 GB 上是 25614。使其成为总量约束的那个不变量在任何主机上都成立:

curl -s 'http://127.0.0.1:4170/daemon/status' | \
  jq '.limits.memory | (.childHeap.maxConcurrentChildren * .childHeap.perChildCeilingMb) <= .modeled.childPoolMb'
# true

2. 什么都没被应用——这是最关键的检查。 打开一个 session 让 qwen --acp 子进程派生出来,然后在两种模式下分别读它的 argv:

ps -o args= -p "$(pgrep -f 'qwen --acp' | head -1)" | tr ' ' '\n' | grep max-old-space-size

该值必须是主机推导的上限(例如 32 GB 主机上是 16384),并且在 --child-heap-mode observe--child-heap-mode off逐字节一致。尤其不能等于 perChildCeilingMb。如果这两次运行有差异,本 PR 的核心主张就是错的。

3. limits.memory.enforced 仍是字面量 false 上一版把它放宽为 boolean,此处已回退,客户端可以继续依赖它:

curl -s 'http://127.0.0.1:4170/daemon/status' | jq '.limits.memory.enforced'
# false

4. 强制模式是真的不存在,而不只是默认关闭。 yargs 路径与 fast path 都必须拒绝它:

qwen serve --child-heap-mode enforce
# error: Invalid values: Argument: child-heap-mode, Given: "enforce", Choices: "off", "observe"

给 triage bot 的说明: 先前的评审要求提供在 enforce 下触发 ChildHeapPoolExhaustedError 的步骤。那次评审读到的是改版前的正文——enforce 与该错误类在本版中均已删除,原因见为什么需要它一节。第 4 步是其替代:它验证两者确已移除。

5. 单元测试套件。

npx vitest run packages/acp-bridge/src/child-heap-policy.test.ts
npx vitest run packages/cli/src/serve/daemon-status.test.ts

证据(前后对比)

N/A —— 无用户可见或 TUI 变更。可观察的差异是 /daemon/status 的载荷:

  "limits": { "memory": {
      "enforced": false,
+     "childHeap": { "mode": "observe", "maxConcurrentChildren": 7, "perChildCeilingMb": 526, "refusals": 0 },
      "modeled": { "childPoolMb": 3687, ... }
  } }

子进程 argv 在两个方向上都未改变——这正是重点,上面第 2 步就是评审者确认它的方式。

测试平台

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

Linux 本地:48 个 acp-bridge 测试 + 175 个 cli 测试,prettier、eslint,以及对 acp-bridgeclisdk-typescripttsc。不变量(maxConcurrentChildren × perChildCeilingMb ≤ childPoolMb)在 2/8/32/256 GB 主机规格上均有测试;observe 被断言使派生 argv 与「无策略工厂」逐字节一致;enforce 被断言在 yargs 与 fast path 两处均被拒绝。macOS 与 Windows 交由 CI——本变更是算术与状态管道,不含平台相关路径。

环境(可选)

Linux 上的 npm run dev daemon;上述断言仅涉及单元测试。

风险与范围

  • 主要风险或权衡: 发布出来的数字会招致一种它们支撑不了的解读——运维看到 refusals: 0 可能会认为分区可以安全强制。并非如此,原因见为什么需要它,该警告现已写入 flag 帮助、三份运维文档和协议文档。此处接受的权衡是:发布一个不约束任何东西的模型——bug(serve): daemon authorises each ACP child 50% of host memory, never divided by child count #8182 的 400 GB 超额授权在强制模式连同其测量手段落地之前仍然敞开。
  • 未验证 / 超出范围: 应用该分区;强制模式所依赖的峰值老生代测量;channel worker 与 MCP 子孙进程的内存(尚无上报路径);以及任何形式的 RSS 约束——因为 --max-old-space-size 只覆盖 V8 老生代,从不包含 Buffers、原生分配或子孙进程。
  • 破坏性变更 / 迁移说明: 无。limits.memory.childHeap 是新增字段,limits.memory.enforced 仍为必需的字面量 false——本 PR 早前版本曾将其放宽为 boolean,该放宽在此已回退,因此客户端契约没有变化。--child-heap-mode 是默认 observe 的新 flag;enforce 不是可接受的取值。

关联 Issue

Refs #8182。叠在 #8423 之上。

🤖 Generated with Claude Code

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot qwen-code-ci-bot 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.

@doudouOUC Thanks for continuing this series — the content here is thorough, but this gate is about shape, not substance: the body doesn't follow the PR template, so requesting changes on that before review proceeds.

What's missing:

  • ## What this PR does / ## Why it's needed — the existing ## What / ## Why content maps over as-is.
  • ## Reviewer Test Plan (### How to verify, ### Evidence (Before & After), ### Tested on) — the one that matters most here. ## Verification describes what you ran; this is the first behaviour-changing PR in the line, and reviewers need steps they can run themselves — e.g. how to watch limits.memory.childHeap.refusals climb under observe, and how to trip a ChildHeapPoolExhaustedError spawn refusal under enforce on a small budget. #8245 earlier in this same series shows the level.
  • ## Risk & Scope — "Two things deliberately not done" and "The contract change" cover most of it; it just needs the template's three bullets (main risk/tradeoff, not validated / out of scope, breaking changes — the limits.memory.enforced literal change belongs in that last one).
  • ## Linked IssuesRefs #8182 is in prose; give it the section.
  • The Chinese <details> translation.

Once the body is on the template, re-run triage (@qwen-code /triage) and it picks up from here.

中文说明

@doudouOUC 感谢继续推进这个系列——内容一如既往地扎实,但这一关卡的是格式而非内容:PR 正文没有遵循 PR 模板,所以在进入评审前先就此提出修改请求。

缺失的部分:

  • ## What this PR does / ## Why it's needed —— 现有的 ## What / ## Why 内容可以直接搬过去。
  • ## Reviewer Test Plan### How to verify### Evidence (Before & After)### Tested on)—— 这是本 PR 最关键的一项。## Verification 描述的是跑过的内容;而这是该系列中第一个改变行为的 PR,评审者需要自己能执行的步骤——例如如何在 observe 模式下观察 limits.memory.childHeap.refusals 增长,以及如何在 enforce 模式、小预算下触发 ChildHeapPoolExhaustedError 拒绝派生。同系列早些时候的 #8245 是一个示范。
  • ## Risk & Scope —— “Two things deliberately not done” 和 “The contract change” 已覆盖大部分内容;只需按模板的三项整理(主要风险/权衡、未验证/超出范围、破坏性变更——limits.memory.enforced 字面量的变化应写在最后一项)。
  • ## Linked Issues —— Refs #8182 目前散在正文里,请放进专门小节。
  • 中文 <details> 翻译。

正文改为模板格式后,重新运行 triage(@qwen-code /triage)即可从这里继续。

Qwen Code · qwen3.8-max

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed the exact head c37affa. The first inline finding is a blocking correctness issue: enforce does not currently maintain the advertised aggregate child-pool bound. The other comments cover an inaccurate status path, missing wire-contract tests, and stale public documentation. Local build and 434 relevant unit tests passed, but none of those tests asserts the aggregate grant invariant. Please address the blocking finding before merging.


return {
// Reported in both modes; only `enforce` lets the caller apply it.
ceilingMb: recommendedChildShareMb(budget, children),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Critical] These per-spawn shares do not form an aggregate bound because only the new child receives P/n; the immutable ceilings already passed to live children are never reduced. The grants therefore accumulate as P + floor(P/2) + ... rather than staying at or below P. With 8 GB available, P is 3687 MB and the seven admitted children receive ceilings totaling 9557 MB. With 32 GB, P is 15360 MB and the repository maximum of 25 children receives 58608 MB in total without a refusal. This still authorizes more old-space than the host and contradicts the central enforce contract. Please use a fixed safe partition or grant/admission accounting that maintains sum(live ceilings) <= childPoolMb, and add a test for that invariant.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and fixed in c0dc50a7f — the arithmetic in this finding is correct, including both worked examples (9557 MB against a 3687 MB pool at seven children on 8 GB; 61355 MB against 15360 MB on 32 GB). The root cause is exactly as stated: V8 cannot lower a running child's ceiling, so per-spawn shares accumulate as P × H(n) instead of staying inside P.

The model is now a fixed partition rather than a share of the pool divided by whoever is live at that instant: every child would receive the same perChildCeilingMb, and maxConcurrentChildren is capped so maxConcurrentChildren × perChildCeilingMb ≤ childPoolMb holds by construction — no ledger, and no dependence on arrival order. That invariant is now asserted across 2/8/32/256 GB hosts.

}),
getMetricsSeries: () => metricsRing.snapshot(),
getTotalSessionAdmissionSnapshot: totalSessionAdmission.snapshot,
getChildHeapPolicySnapshot: () => managedChildHeapPolicy?.snapshot(),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] managedChildHeapPolicy is also created when deps.bridge is injected, but that bridge bypasses the channelFactory carrying this policy. This status callback can consequently report enforced: true while no child is being sized by the policy. Please publish this snapshot only when the daemon-created bridge actually owns the factory, or let the injected bridge supply its real policy state, with a regression test for the injected-bridge path.

res.set('Retry-After', '5');
res.status(503).json({
error: err.message,
code: 'child_heap_pool_exhausted',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] Please add direct tests for this REST mapping and the ACP counterpart in dispatch.ts. The new contract includes 503, Retry-After, child_heap_pool_exhausted, and the data fields; the current spawn-policy tests cannot catch a transport-layer mapping regression. The existing error-response.test.ts and dispatch-error.test.ts are the natural coverage points.

| `--compacted-replay-max-bytes <n>` | positive integer | `4194304` | Byte cap for the bounded in-memory replay snapshot returned by `POST /session/:id/load`; hard cap is `268435456`. |
| `--memory-budget-mb <n>` | integer in `[1024, 1048576]` | 50% of cgroup-constrained or host memory, capped at the flag maximum (1048576 MB) | Total memory budget for the daemon process tree, capped at resolved available memory. Observed and reported under `limits.memory` in daemon status; it does not size any child process. Boot rejects out-of-range values. |
| `--memory-pressure-mode <mode>` | `off` \| `observe` | `observe` | Whether the daemon derives a memory-pressure level from its own RSS and V8 heap. Both modes report `runtime.memory.pressure`; only `observe` raises `daemon_memory_pressure`. Root process only; no remediation. |
| `--child-heap-mode <mode>` | `off \| observe \| enforce` | `observe` | How the per-child heap share derived from the budget is used. `enforce` sizes each child by the live child count and refuses a spawn the pool cannot cover; `observe` computes both but applies neither. |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] The adjacent memory-budget row still says that the budget does not size any child, which is false in enforce mode. The same obsolete observation-only contract remains in the qwen serve help text, 20-quickstart-operations.md, qwen-serve.md, daemon/CLI type comments, and the TypeScript SDK memory comments. Please update these public descriptions together so operators and SDK users do not receive contradictory guidance.

doudouOUC and others added 5 commits August 4, 2026 14:16
Groundwork for #8182 step 2. Nothing calls any of this yet, so no child
is sized differently and no spawn is refused.

`ProcessRegistry.committedProcessCount` counts attached children plus
reservations that have not attached. That is the figure admission has to
key on: `reserve()` inserts its token synchronously before `spawn()`, so
two racing spawns each see the other, while neither appears in
`activeProcessCount` until its child attaches. A child leaves the count
on exit rather than when `terminate()` starts, so a channel swap counts
twice while the old process winds down — deliberate, since its memory is
still resident.

`getAcpMemoryArgs(explicitMb?)` takes an optional share that bypasses
both the module cache and the raise-only guard. Both bypasses are
load-bearing. The cache, because the share depends on how many children
are live now rather than on the host. The guard, because a
budget-derived share is normally *below* the daemon's own heap limit, so
routing it through `targetMB > currentLimitMB` would drop the flag,
silently restore the overcommit, and leave every test green — the trap
#8182 calls out. The regression test asserts the flag survives 614 MB
against a multi-GB runner, and mutation-checking it by reinstating the
guard fails two tests.

`createChildHeapPolicy` holds the mode, the budget, and the would-be
refusal counter, and answers `decide(concurrentChildren)`. The refusal
is derived from the unclamped quotient, not from
`recommendedChildShareMb`, because that function clamps *up* to the
512 MB floor: past the point where the pool stops covering the count its
answer saturates and can no longer distinguish "barely does not fit"
from "wildly does not fit".

`ChildHeapPoolExhaustedError` with both transport mappings — REST 503
with Retry-After, ACP `child_heap_pool_exhausted` — added together,
since the two mappings are hand-written and drift silently otherwise.
Refusing at spawn rather than at registration is the correction #8182
demands: registration allocates nothing, so this surfaces as "no new
session in this workspace right now", which is true and retryable.

Refs #8182.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the primitives from the previous commit into the spawn path, behind
`--child-heap-mode off | observe | enforce`, default `observe`.

Under `enforce` a child's `--max-old-space-size` is a share of the child
pool divided by the children concurrently committed at the moment it
spawns — read from the shared ProcessRegistry after `reserve()`, so two
racing spawns each see the other. When the pool cannot cover another
child at the 512 MB floor the spawn is refused with
ChildHeapPoolExhaustedError, which is what turns a per-child ceiling into
an aggregate bound: concurrent children can never exceed pool/512.

Keyed on concurrency, never on registrations. A dormant workspace has no
child, so it costs nothing — the specific correction #8182 records
against the withdrawn proposal, which would have shrunk a lone live child
to 614 MB because of 24 idle registrations.

Default `observe` computes the share and the admission decision and
applies neither, counting the refusals that would have happened. The
divisor has never been checked against a real multi-workspace deployment,
and a non-zero count is how an operator learns enforcement would have
broken them without being broken. It also catches the case worth
worrying about: a channel swap counts the dying child alongside its
replacement, so on a saturated pool enforcement could refuse a restart
and leave that workspace with no child at all. Excluding terminating
children would authorise real overcommit to dodge a hypothetical
refusal, so the count reports it instead.

Ceilings already granted are not revisited — V8 cannot lower them — so
granted ceilings transiently exceed the pool. Acceptable: the flag is a
ceiling, not a reservation, and a workspace with no live sessions has no
child and picks up the current share on its next spawn.

`limits.memory.enforced` stops being a required literal `false`. #8245
made it one so a client could never mistake that namespace for
enforcement that had not shipped; it has now, so the field is a boolean
derived from the mode — and stays `false` under `observe`, which applies
nothing.

Refs #8182.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two sentences in the protocol doc described the memory section as
unconditionally observational: "a required `enforced: false`", and "no
child spawn argument derives from these values, and no request is
refused on their basis". Both are false under `--child-heap-mode
enforce`, so both are rewritten rather than left to rot — `enforced` is
now documented as the boolean that answers exactly this, and the refusal
is documented with its wire shape on both transports.

Also documents `childHeap.refusals` as the calibration signal, since a
would-be-refusal count is useless if operators do not know to read it
before switching to `enforce`; the flag row in the three operator docs;
and the design doc's Part 1, which listed applying a share as a
compatibility risk without recording how that was resolved.

The end-to-end test asserts the policy reaches a real booted daemon's
status with `enforced: false` under the default mode — the wire type in
that test is a hand-written mirror, so its `enforced: false` literal had
to widen too, which is the check that caught the type not being widened
everywhere.

Refs #8182.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`enforced` was only ever asserted false — the unit tests build no policy
and the end-to-end daemon runs the default `observe` mode, so the branch
that makes the field worth having was untested. Hardcoding it back to
`false` passed everything.

Also pins `childHeap: null` as distinct from a policy in `off` mode: the
first says no policy exists (direct-embed, or the bootstrap window before
the runtime is built), the second says one exists and computes nothing.

Refs #8182.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review was right that the previous design did not deliver the aggregate
bound it claimed. Sizing each child by the count live at *its* spawn
bounds the child count but not the memory: V8 cannot lower a running
child's ceiling, so grants accumulate as P + P/2 + P/3 + ... = P x H(n).
Reproduced exactly — 9557 MB authorised against a 3687 MB pool at seven
children on an 8 GB host, and 61355 MB against 15360 MB at the limit on
32 GB. That is 2.6x and 4x the pool, which is what the policy exists to
prevent.

Grant accounting alone does not fix it: the first child would take the
whole pool and the second would be refused immediately. Keeping the
invariant requires early children not to receive the whole pool, so the
ceiling is now a fixed partition — childPoolMb / maxConcurrentChildren,
constant for every child, with maxConcurrentChildren itself derived from
the pool and capped at MAX_DAEMON_WORKSPACES. The sum is then
n x ceiling <= pool by construction, with no ledger of outstanding
grants and no dependence on arrival order. Tested as an invariant across
four host sizes: fill the daemon to its admission limit and the
authorised total still fits.

The cost is deliberate and now documented rather than hidden: a lone
workspace on a 32 GB host gets 614 MB rather than the pool, because any
child may still be running when the house fills. An 8 GB host admits
seven concurrent children at 526 MB each.

Also from review:

- The policy is no longer built for an injected `deps.bridge`. That
  bridge carries its own channel and never reaches the factory the
  policy rides on, so status could report `enforced: true` while nothing
  was being sized.
- Both transport mappings now have direct tests. They are hand-written
  beside each other and drift silently; the spawn-policy tests cannot
  catch a wire regression.
- Swept the "does not size any child" claim, which enforce makes false,
  out of the CLI help text, ServeOptions docs, the two operator tables,
  and the e2e header comment. The 17-configuration table realigns
  wholesale because that cell was its widest — whitespace only.

Refs #8182.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@doudouOUC
doudouOUC force-pushed the agent/child-heap-admission branch from c37affa to c0dc50a Compare August 4, 2026 06:38
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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

中文

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

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

All four addressed — c0dc50a7.

[Critical] The aggregate bound — you're right, and my claim was wrong.

Reproduced your arithmetic exactly. Sizing each child by the count live at its spawn bounds the child count but not the memory, because V8 cannot lower a running child's ceiling — grants accumulate as P + P/2 + P/3 + … = P × H(n):

host pool admitted Σ granted
8 GB 3687 MB 7 9557 MB 2.59×
32 GB 15360 MB 25 61355 MB 3.99×

(The 8 GB figure matches yours to the megabyte.) I conflated two things in the PR description: I bounded the count and called it an aggregate bound. The design doc did say granted ceilings "transiently" exceed the pool, but that word was wrong too — for a monotonically growing set it is the steady state.

Worth recording why plain grant accounting does not fix it: the first child takes the whole pool, so the second sees zero remaining and is refused immediately. Holding the invariant requires early children not to receive the whole pool.

So the ceiling is now a fixed partitionchildPoolMb / maxConcurrentChildren, constant for every child, with maxConcurrentChildren derived from the pool and capped at MAX_DAEMON_WORKSPACES. The sum is n × ceiling ≤ pool by construction: no ledger, no dependence on arrival order. The invariant is tested directly across four host sizes — fill the daemon to its admission limit, assert the authorised total still fits — plus a test that the ceiling does not vary with the live count, since that constancy is what the invariant rests on.

The cost is real and now stated rather than buried: a lone workspace on a 32 GB host gets 614 MB instead of the pool, because any child may still be running when the house fills. An 8 GB host admits 7 concurrent children at 526 MB each — pinned in a test, since that is the number an operator plans against.

[P2] Injected bridge — fixed. The policy is no longer constructed when deps.bridge is injected; that bridge carries its own channel and never reaches the factory the policy rides on, so status could have reported enforced: true while nothing was being sized.

[Suggestion] Transport mappings — both now have direct tests in error-response.test.ts and dispatch-error.test.ts, asserting 503, Retry-After, child_heap_pool_exhausted, and the data fields. You're right that the spawn-policy tests cannot catch a wire regression, and the two mappings are hand-written beside each other precisely where drift is invisible.

[Suggestion] Obsolete observation-only text — swept: the CLI help text, the ServeOptions doc, both operator tables, and the e2e header comment. Note 17-configuration.md shows a wholesale table realign — that cell was the table's widest, so prettier re-derives the column width; the change is whitespace apart from the one row.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-reviewed the full 30-file diff at exact head c0dc50a against stacked base 361413a. The fixed partition repairs the previous aggregate-bound defect for normal positive pools, and the injected-bridge and REST/ACP mapping fixes are sound. However, two new blocking correctness issues remain: a zero child pool produces an ineffective zero V8 ceiling, and the observe rollout signal cannot detect whether workloads fit the much smaller fixed ceiling. A third comment covers the now-inaccurate refusal contract, and a fourth covers stale public documentation and PR metadata. 119 targeted tests passed; the missing boundary and calibration assertions are the gaps described inline.

// The cost is real and deliberate: a lone workspace on a 32 GB host gets
// 614 MB rather than the whole pool. Every admitted child is sized for a
// full house, because any child may still be running when the house fills.
const maxConcurrentChildren = Math.max(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Critical] Forcing maxConcurrentChildren to at least one breaks the aggregate invariant when the resolved child pool is zero. With 512 MB available, the derived budget is 256 MB, the root reserve consumes all 256 MB, and childPoolMb is 0. This code then computes maxConcurrentChildren=1 and perChildCeilingMb=0, so decide(1) admits the child and the spawn path passes --max-old-space-size=0. On the required Node 22 runtime, that value means the default heap rather than a zero ceiling (4144 MB on Node 22.22.3), so enforce authorizes gigabytes against a zero pool. At 1024 MB available it similarly admits a 256 MB child despite the documented 512 MB minimum. Please represent zero/sub-floor capacity as no admissible child or reject enforce for an insufficient budget, and cover both the zero-pool argv behavior and the sub-floor boundary.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and fixed in 25e69d2c0. The clamp was mine and the consequence is worse than the finding even states: --max-old-space-size=0 is not a zero ceiling, it is V8's default heap — roughly 4 GB — so forcing "at least one child" against a pool of 0 authorised gigabytes rather than nothing.

Math.max(1, …) is gone. A pool that cannot cover one child at MIN_CHILD_HEAP_MB now reports maxConcurrentChildren: 0 and perChildCeilingMb: **null** — null rather than 0, precisely so no caller can pass the zero through to V8 and get the default heap.

Worth flagging for the next round: the test that should have caught this had enshrined the bug as expected behaviour — it was named "always admits at least one child, however small the pool". It is inverted now, and the 512 MB case (256 MB budget, root reserve consuming all of it, pool 0) is pinned directly.

Comment thread packages/cli/src/serve/daemon-status.ts Outdated
childHeap: {
mode: 'off' | 'observe' | 'enforce';
/**
* Spawns refused, or — under `observe` — that would have been refused.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Critical] This counter cannot establish that enabling enforce is safe after switching to a fixed partition. On a 32 GB host, observe leaves a single child on the 16384 MB legacy ceiling and records zero refusals, while enforce immediately gives that same child only 614 MB. A workload using more than 614 MB old space is therefore healthy with refusals=0 in observe and OOMs after the switch. The status mapping also drops the already-computed perChildCeilingMb and maxConcurrentChildren, and no heap-used/high-water value is compared with the proposed cap. Zero refusals proves only that the spawn count stayed within the admission limit. Please stop presenting it as an enforcement-safety signal; at minimum publish the proposed fixed ceiling and document that workload validation is still required, or add observation that can detect ceiling incompatibility.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and this one changed the shape of the PR: enforce is removed entirely in 25e69d2c0.

The finding is right that the counter measures the wrong thing. While observing, children run on the host-derived ceiling (16384 MB on a 32 GB host), so a workload needing more old space than perChildCeilingMb is perfectly healthy with refusals: 0 and OOMs the instant the partition is applied. refusals is admission pressure, not ceiling adequacy — and enforcing on a signal that cannot answer the question it is being read for is how a healthy daemon gets switched into an OOM loop.

Rather than keep enforce behind a warning, it is gone: ChildHeapMode is now 'off' | 'observe', and the machinery that existed only to apply the partition went with it instead of shipping unreachable — getAcpMemoryArgs(explicitMb?), ChildHeapPoolExhaustedError and both transport mappings, and limits.memory.enforced, which reverts to the required literal false. Net −310 lines.

The enforcing mode will ship with the measurement that justifies it: peak old-space per child (not rss, which is what the child self-reports today, and not heapUsed, which includes the new generation), compared against perChildCeilingMb. That measurement has to be taken inside the child — an outside sampler misses GC-time peaks — so it is its own change. Recorded in #8182 and #8051.

What operators get instead is perChildCeilingMb and maxConcurrentChildren published, so the partition can be judged against a known workload rather than inferred from a counter that cannot judge it. Every claim that refusals: 0 implies enforcement is safe has been removed from the flag help, the three operator docs, the protocol doc, and the design doc.

Comment thread packages/acp-bridge/src/bridgeErrors.ts Outdated
minChildHeapMb: number,
) {
super(
`Daemon child heap pool (${childPoolMb} MB) cannot cover ` +

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] This error describes a condition the fixed-partition policy no longer tests. On a 32 GB host the partition is 25 children at 614 MB; reservation 26 is refused, but 26 * 512 MB is 13312 MB, which is below the 15360 MB pool. The response therefore says the pool cannot cover 26 children at the 512 MB minimum when it demonstrably can, and raising the budget may not help because the admission limit is also capped at the repository workspace maximum. Please report the actual fixed-partition limit and per-child ceiling, and update the REST/ACP payload tests accordingly.

| `--compacted-replay-max-bytes <n>` | positive integer | `4194304` | Byte cap for the bounded in-memory replay snapshot returned by `POST /session/:id/load`; hard cap is `268435456`. |
| `--memory-budget-mb <n>` | integer in `[1024, 1048576]` | 50% of cgroup-constrained or host memory, capped at the flag maximum (1048576 MB) | Total memory budget for the daemon process tree, capped at resolved available memory. Reported under `limits.memory`; sizes each ACP child only under `--child-heap-mode enforce`. Boot rejects out-of-range values. |
| `--memory-pressure-mode <mode>` | `off` \| `observe` | `observe` | Whether the daemon derives a memory-pressure level from its own RSS and V8 heap. Both modes report `runtime.memory.pressure`; only `observe` raises `daemon_memory_pressure`. Root process only; no remediation. |
| `--child-heap-mode <mode>` | `off \| observe \| enforce` | `observe` | How the per-child heap share derived from the budget is used. `enforce` sizes each child by the live child count and refuses a spawn the pool cannot cover; `observe` computes both but applies neither. |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] This still documents the superseded pool/live-child algorithm; enforce now gives every child one fixed partition independent of the live count. The same old algorithm or observation-only contract remains in 20-quickstart-operations.md, qwen-serve-protocol.md, docs/users/qwen-serve.md, ServeOptions and daemon-status comments, and the TypeScript SDK. The PR title and body also still describe the pre-fix dynamic policy, including the claim that existing grants exceed the pool. Please update the full public contract and PR description together; the existing PR-template Changes Requested is also still outstanding.

Review established that the refusal counter cannot tell an operator
whether enforcement is safe, and that is the ground the enforcing mode
stood on. While observing, children run on the host-derived ceiling
(16384 MB on a 32 GB host), so a workload needing 2 GB of old space is
healthy with zero refusals and OOMs the moment a 614 MB partition is
applied. The counter measures admission pressure, not ceiling adequacy.

Rather than ship a switch with no safe way to decide when to turn it on,
`enforce` is removed. `--child-heap-mode` is `off | observe`, and the
mode that would apply the partition arrives with the measurement that
justifies it: peak old-space per child, compared against the modeled
ceiling. That is a real measurement chain — the child reports rss and
cpu today, and `--max-old-space-size` bounds old space specifically, so
neither rss nor heapUsed answers the question.

With nothing applying the partition, the machinery that existed only to
apply it goes too rather than shipping unreachable:
`getAcpMemoryArgs(explicitMb?)`, `ChildHeapPoolExhaustedError` and both
transport mappings, and `limits.memory.enforced` reverts to the required
literal `false` it was before. The spawn path is untouched again; the
factory asks the policy what it would decide purely so the count is
real.

Also fixes the zero-pool defect review found, which the removed clamp
caused: forcing at least one admissible child on a 512 MB host — where
the root reserve consumes the whole 256 MB budget — produced a ceiling
of 0, and `--max-old-space-size=0` is V8's *default* heap, not a zero
ceiling. A pool that cannot cover one child at the floor now reports
`maxConcurrentChildren: 0` and `perChildCeilingMb: null`, and the test
that enshrined the old behaviour is inverted.

Status now publishes `maxConcurrentChildren` and `perChildCeilingMb`, so
an operator can judge the partition against their own workload — the
substitute for a counter that cannot judge it for them. Every claim that
a zero refusal count means the partition is safe to apply is removed
from the flag help, the operator docs, the protocol doc, and the design
doc.

Refs #8182.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@doudouOUC doudouOUC changed the title feat(serve): size each ACP child by concurrently live children refactor(serve): model a per-child heap partition of the daemon budget Aug 4, 2026
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Took all four, and the second one changed the shape of the PR — 25e69d2c, net −310 lines.

[Critical] The refusal counter cannot establish enforcement safety. You're right, and this is the one that mattered most. While observing, children sit on the host-derived ceiling — 16384 MB on a 32 GB host — so a workload needing 2 GB of old space reports refusals: 0 and OOMs the moment a 614 MB partition is applied. The counter measures admission pressure, not ceiling adequacy.

That was the ground enforce stood on ("observe first, then enforce"), so enforce is removed rather than papered over. Answering the question properly needs peak old-space per child compared against the modeled ceiling — and notably neither figure we have works: the child reports rss, and heapUsed includes new space, while --max-old-space-size bounds old space specifically. That is a measurement chain of its own (v8.getHeapSpaceStatistics() peak tracked inside the child, since sampling from outside misses peaks between polls), and the enforcing mode ships with it.

Since nothing applies the partition now, everything that existed only to apply it went too rather than shipping unreachable: getAcpMemoryArgs(explicitMb?), ChildHeapPoolExhaustedError and both transport mappings, and limits.memory.enforced reverts to the required literal false it was before this PR. The spawn path is untouched again.

[Critical] Zero/sub-floor pool. Confirmed and fixed — and it was my clamp that caused it. Forcing at least one admissible child meant a 512 MB host, where the root reserve consumes the entire 256 MB budget, modeled a ceiling of 0; --max-old-space-size=0 is V8's default heap, so that would have modeled gigabytes against an empty pool. Now maxConcurrentChildren: 0 and perChildCeilingMb: null, with 512 MB and 1024 MB both covered. Worth noting the test I had written — "always admits at least one child, however small the pool" — had enshrined the defect as expected behaviour; it is inverted.

[P2] The error message described an untested condition. Correct: 26 × 512 = 13312 < 15360, so the pool demonstrably could cover them and the real limit was the partition. Moot now that the error is gone, and the enforcing PR will carry a message stating the actual partition limit and ceiling.

[Suggestion] Public contract. Swept: flag help, ServeOptions, all three operator tables, the protocol doc, the daemon-status and SDK comments, and the design doc's Part 1 — which now records the P × H(n) finding and why applying is deferred. PR title and body rewritten to describe the modeling-only contract.

On the status mapping dropping the computed figures — fixed, and it turned out to be the substitute for the counter you took apart: maxConcurrentChildren and perChildCeilingMb are now published so an operator can judge the partition against a workload they know, rather than trusting a number that cannot judge it for them.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed the updated modeling-only revision at 25e69d2c. The previous enforcement and zero-pool findings are fixed or made moot. I found three reproducible correctness issues plus one stale public contract comment; details are inline.


return {
decide(concurrentChildren) {
if (mode === 'off') return { refuse: false };

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] off only skips incrementing refusals here; maxConcurrentChildren and perChildCeilingMb were already computed, and snapshot() still returns them. Because runQwenServe constructs this policy for childHeapMode: 'off' and daemon status publishes the snapshot, an 8 GiB host still reports a 7-child / 526 MB partition even though the CLI help, ServeOptions, and user docs all promise that off models nothing. Please make the off path omit/disable the partition (and assert the full snapshot/status shape), or change the public contract consistently if static modeling is intended to remain enabled.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed — snapshot() returns maxConcurrentChildren and perChildCeilingMb unconditionally, so a daemon run with --child-heap-mode off still publishes a partition it was told not to model. The flag's own documentation says "off — do not model it", so the code contradicts its stated contract.

Not fixed in e95dda03e, because the honest fix is a wire decision rather than a local one, and the finding points at something larger: now that enforce is gone, off and observe differ only in whether refusals increments. Both publish the same model and neither applies anything.

Three ways out, none obviously right:

  1. Do not construct the policy when the mode is off, so status omits childHeap entirely. Simplest, but a client can no longer distinguish off from a daemon predating the field.
  2. Widen maxConcurrentChildren to number | null and report both figures as null under off, keeping mode: 'off' visible. Most accurate, but it changes the wire type and the SDK mirror. Reporting 0 / null instead would collide with the zero-pool state, which this policy deliberately distinguishes.
  3. Change the documentation to say off only stops the counting, and let the model stay published.

Leaning toward 2, since the same tradeoff already produced perChildCeilingMb: number | null rather than a 0 that V8 would read as its default heap — the field saying something true was worth the type change there too. Flagging it for a decision rather than picking unilaterally on already-merged code; happy to take direction.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 4eb1a85f2 (on agent/daemon-memory-observe, where this code now lives).

Both figures are null under off. Widening maxConcurrentChildren to number | null was the part worth deciding rather than assuming: 0 was available but already means something else — the computed answer for a pool too small to host one child at the 512 MB floor — so reusing it would tell an operator who disabled the model that their host cannot run anything. Same reasoning that made perChildCeilingMb a null instead of a 0 earlier in this PR.

That leaves three states a client can now distinguish, each with a test:

state childHeap
no policy built (direct-embed, bootstrap window) null
policy exists, models nothing { mode: 'off', maxConcurrentChildren: null, perChildCeilingMb: null, refusals: 0 }
policy models the partition { mode: 'observe', maxConcurrentChildren: 7, perChildCeilingMb: 526, … }

You also found a test whose name outran its assertions: models nothing at all when off checked only refusals, which is why this shipped. It now asserts the figures, and a sibling test pins 7 / 526 under observe on the same budget — so nulling them unconditionally fails the second test rather than quietly satisfying the first. Mutation-verified both ways (modeled = true fails the off test; modeled = false fails four observe tests).

Docs updated: the protocol doc's childHeap description, 17-configuration.md, 20-quickstart-operations.md, and qwen-serve.md.

// Observation only: the policy is asked what it *would* decide so the
// refusal count is real, but nothing here acts on the answer — no derived
// ceiling reaches the child and no spawn is refused.
options.childHeapPolicy?.decide(processRegistry.committedProcessCount);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P2] This call is outside the try that cancels the reservation. A supplied ChildHeapPolicy is part of the public createSpawnChannelFactory options, so if decide() throws, the factory rejects but the reservation created on line 156 remains forever; reproduced with committedProcessCount === 1 after the rejection. That permanently inflates every later observation (and would become a false admission refusal if this seam is later enforced). Please include policy evaluation in the reservation-cancellation failure path and add the throwing-policy regression test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed and fixed in e95dda03e. Both decide() and getAcpMemoryArgs() now run inside the try that cancels the reservation.

The reachability argument is the part worth keeping: our own decide() is pure arithmetic and cannot throw, so this is not a live defect today — but childHeapPolicy is a public createSpawnChannelFactory option, which makes decide() caller code. A supplied implementation that throws leaks the token for the process lifetime, and every later spawn then sees an inflated committedProcessCount. That is worth closing on a public extension point regardless of what our own implementation happens to do.

The regression test is mutation-verified rather than merely green — reverting the move reproduces your figure exactly:

× releases the reservation when a supplied policy throws
  → expected 1 to be +0

// `enforced` has to stay false or the field means "the feature exists"
// rather than "children are being sized by this".
expect(memory?.enforced).toBe(false);
expect(memory?.childHeap).toEqual({ mode: 'observe', refusals: 0 });

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[P1] This exact equality assertion fails on the current head because the production response intentionally also contains maxConcurrentChildren: 7 and perChildCeilingMb: 526. Running npx vitest run src/serve/run-qwen-serve.test.ts --disableConsoleIntercept --coverage.enabled=false gives 217 passed / 1 failed at this line. Please assert the full published shape (or use toMatchObject if extras are intentionally irrelevant) so the updated suite is green and the new wire fields are actually covered by this end-to-end status test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed and fixed in e95dda03e — reproduced exactly as reported:

AssertionError: expected { mode: 'observe', …(3) } to deeply equal { mode: 'observe', refusals: +0 }
+   "maxConcurrentChildren": 6,
+   "perChildCeilingMb": 524,
 Tests  1 failed | 217 passed (218)

(6 / 524 rather than 7 / 526 here — this suite boots a real daemon, so the pool follows the machine, which is itself part of the fix.)

The local type restating the wire shape was short the same two fields, so nothing flagged the drift. Both are filled in now.

The assertion stays toEqual rather than relaxing to toMatchObject, so an unannounced field added to the wire still fails here; only the two host-derived figures get matchers. What they have to satisfy is pinned separately instead of by value:

expect(
  (memory?.childHeap?.maxConcurrentChildren ?? 0) *
    (memory?.childHeap?.perChildCeilingMb ?? 0),
).toBeLessThanOrEqual(memory?.modeled.childPoolMb ?? 0);

That product is the reason a fixed ceiling bounds anything at all, so it is worth an assertion in its own right — more than the two literals were.

218 passed / 0 failed on this branch.

* plus every `qwen --acp` child it spawns. When unset, derived as half of
* the cgroup-constrained or host memory. Currently observed and reported
* only; it does not yet size any child.
* only until `childHeapMode` is `enforce`, which sizes every ACP child from

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] This public option comment still promises a future childHeapMode: 'enforce' that sizes/refuses children, but the following union has no such mode and this revision explicitly removes it until a separate old-space measurement exists. Please restore the observation-only description here; the newly added bootstrap/policy comments in run-qwen-serve.ts and the childHeapPolicy option comment in spawnChannel.ts also retain claims about sizing/enforced state from the removed implementation and should be updated in the same cleanup.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed and fixed in e95dda03e. The comment promised a childHeapMode: 'enforce' that sizes children and refuses spawns; no such mode exists after this revision. It now states the observation-only contract and says what applying the partition is waiting on:

Observed and reported only. No child is sized from it and no spawn is refused on its basis: childHeapMode: 'observe' models a partition of it and publishes the model, but there is no mode that applies one. Sizing children arrives with the peak old-space measurement that can tell an operator beforehand whether their workload fits the partition.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Body is now on the template: ## What this PR does / ## Why it's needed / ## Reviewer Test Plan (with ### How to verify, ### Evidence (Before & After), ### Tested on) / ## Risk & Scope / ## Linked Issues, plus the Chinese <details>.

One correction to the triage note, since it changes what the test plan should contain: the review read the pre-revision body. enforce and ChildHeapPoolExhaustedError were both deleted in this revision — the reason is under Why it's needed, and the short version is that refusals: 0 cannot establish that enforcing is safe, because children run on the much larger host-derived ceiling while observing. So there are no steps to trip a ChildHeapPoolExhaustedError; step 4 of the test plan verifies the opposite — that --child-heap-mode enforce is rejected by both yargs and the fast path.

limits.memory.enforced likewise did not widen to boolean; that widening was reverted, so it stays the required literal false and there is no breaking change. Recorded under Risk & Scope anyway, since the earlier revision did change it.

Step 2 is the one worth a reviewer's time: child argv must be byte-identical under observe and off. That is the whole safety claim of this PR.

@qwen-code /triage

@doudouOUC
doudouOUC merged commit 8e4b6f8 into agent/daemon-memory-observe Aug 4, 2026
125 of 127 checks passed
@doudouOUC
doudouOUC deleted the agent/child-heap-admission branch August 4, 2026 12:32
doudouOUC added a commit that referenced this pull request Aug 4, 2026
Three findings review raised against #8508 after the partition became
observation-only, all still live on this branch now that it has merged.

The status assertion in `run-qwen-serve.test.ts` failed on head: it used
`toEqual` against `{ mode, refusals }` while the wire also carries
`maxConcurrentChildren` and `perChildCeilingMb`, so the suite was red at
217 passed / 1 failed. The local type restating the wire shape was short
the same two fields. Both are filled in, and the assertion stays `toEqual`
so an unannounced field still fails it — the two derived figures get
matchers because this suite boots a real daemon and the pool follows the
machine. What they have to satisfy is now pinned separately: a fixed
ceiling times the number admitted must fit inside the pool it partitions,
which is the whole reason the partition bounds anything.

`decide()` and `getAcpMemoryArgs()` ran between `reserve()` and the `try`
that cancels the reservation. `childHeapPolicy` is a public
`createSpawnChannelFactory` option, so `decide()` is caller code and may
throw; the spawn then rejected with the token held for the process
lifetime, inflating `committedProcessCount` for every later spawn. Both
calls move inside the `try`. The regression test is mutation-verified —
reverting the move gives `expected 1 to be +0`.

`ServeOptions.memoryBudgetMb` still promised a `childHeapMode: 'enforce'`
that sizes children and refuses spawns. No such mode exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
doudouOUC added a commit that referenced this pull request Aug 5, 2026
#8508)

* feat(serve): add the child-heap admission primitives, unwired

Groundwork for #8182 step 2. Nothing calls any of this yet, so no child
is sized differently and no spawn is refused.

`ProcessRegistry.committedProcessCount` counts attached children plus
reservations that have not attached. That is the figure admission has to
key on: `reserve()` inserts its token synchronously before `spawn()`, so
two racing spawns each see the other, while neither appears in
`activeProcessCount` until its child attaches. A child leaves the count
on exit rather than when `terminate()` starts, so a channel swap counts
twice while the old process winds down — deliberate, since its memory is
still resident.

`getAcpMemoryArgs(explicitMb?)` takes an optional share that bypasses
both the module cache and the raise-only guard. Both bypasses are
load-bearing. The cache, because the share depends on how many children
are live now rather than on the host. The guard, because a
budget-derived share is normally *below* the daemon's own heap limit, so
routing it through `targetMB > currentLimitMB` would drop the flag,
silently restore the overcommit, and leave every test green — the trap
against a multi-GB runner, and mutation-checking it by reinstating the
guard fails two tests.

`createChildHeapPolicy` holds the mode, the budget, and the would-be
refusal counter, and answers `decide(concurrentChildren)`. The refusal
is derived from the unclamped quotient, not from
`recommendedChildShareMb`, because that function clamps *up* to the
512 MB floor: past the point where the pool stops covering the count its
answer saturates and can no longer distinguish "barely does not fit"
from "wildly does not fit".

`ChildHeapPoolExhaustedError` with both transport mappings — REST 503
with Retry-After, ACP `child_heap_pool_exhausted` — added together,
since the two mappings are hand-written and drift silently otherwise.
Refusing at spawn rather than at registration is the correction #8182
demands: registration allocates nothing, so this surfaces as "no new
session in this workspace right now", which is true and retryable.

Refs #8182.

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

* feat(serve): size each ACP child by concurrently live children

Wires the primitives from the previous commit into the spawn path, behind
`--child-heap-mode off | observe | enforce`, default `observe`.

Under `enforce` a child's `--max-old-space-size` is a share of the child
pool divided by the children concurrently committed at the moment it
spawns — read from the shared ProcessRegistry after `reserve()`, so two
racing spawns each see the other. When the pool cannot cover another
child at the 512 MB floor the spawn is refused with
ChildHeapPoolExhaustedError, which is what turns a per-child ceiling into
an aggregate bound: concurrent children can never exceed pool/512.

Keyed on concurrency, never on registrations. A dormant workspace has no
child, so it costs nothing — the specific correction #8182 records
against the withdrawn proposal, which would have shrunk a lone live child
to 614 MB because of 24 idle registrations.

Default `observe` computes the share and the admission decision and
applies neither, counting the refusals that would have happened. The
divisor has never been checked against a real multi-workspace deployment,
and a non-zero count is how an operator learns enforcement would have
broken them without being broken. It also catches the case worth
worrying about: a channel swap counts the dying child alongside its
replacement, so on a saturated pool enforcement could refuse a restart
and leave that workspace with no child at all. Excluding terminating
children would authorise real overcommit to dodge a hypothetical
refusal, so the count reports it instead.

Ceilings already granted are not revisited — V8 cannot lower them — so
granted ceilings transiently exceed the pool. Acceptable: the flag is a
ceiling, not a reservation, and a workspace with no live sessions has no
child and picks up the current share on its next spawn.

`limits.memory.enforced` stops being a required literal `false`. #8245
made it one so a client could never mistake that namespace for
enforcement that had not shipped; it has now, so the field is a boolean
derived from the mode — and stays `false` under `observe`, which applies
nothing.

Refs #8182.

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

* docs(serve): correct the claims child-heap enforcement makes false

Two sentences in the protocol doc described the memory section as
unconditionally observational: "a required `enforced: false`", and "no
child spawn argument derives from these values, and no request is
refused on their basis". Both are false under `--child-heap-mode
enforce`, so both are rewritten rather than left to rot — `enforced` is
now documented as the boolean that answers exactly this, and the refusal
is documented with its wire shape on both transports.

Also documents `childHeap.refusals` as the calibration signal, since a
would-be-refusal count is useless if operators do not know to read it
before switching to `enforce`; the flag row in the three operator docs;
and the design doc's Part 1, which listed applying a share as a
compatibility risk without recording how that was resolved.

The end-to-end test asserts the policy reaches a real booted daemon's
status with `enforced: false` under the default mode — the wire type in
that test is a hand-written mirror, so its `enforced: false` literal had
to widen too, which is the check that caught the type not being widened
everywhere.

Refs #8182.

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

* test(serve): cover both branches of the enforced tripwire

`enforced` was only ever asserted false — the unit tests build no policy
and the end-to-end daemon runs the default `observe` mode, so the branch
that makes the field worth having was untested. Hardcoding it back to
`false` passed everything.

Also pins `childHeap: null` as distinct from a policy in `off` mode: the
first says no policy exists (direct-embed, or the bootstrap window before
the runtime is built), the second says one exists and computes nothing.

Refs #8182.

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

* fix(serve): partition the child pool so granted ceilings stay inside it

Review was right that the previous design did not deliver the aggregate
bound it claimed. Sizing each child by the count live at *its* spawn
bounds the child count but not the memory: V8 cannot lower a running
child's ceiling, so grants accumulate as P + P/2 + P/3 + ... = P x H(n).
Reproduced exactly — 9557 MB authorised against a 3687 MB pool at seven
children on an 8 GB host, and 61355 MB against 15360 MB at the limit on
32 GB. That is 2.6x and 4x the pool, which is what the policy exists to
prevent.

Grant accounting alone does not fix it: the first child would take the
whole pool and the second would be refused immediately. Keeping the
invariant requires early children not to receive the whole pool, so the
ceiling is now a fixed partition — childPoolMb / maxConcurrentChildren,
constant for every child, with maxConcurrentChildren itself derived from
the pool and capped at MAX_DAEMON_WORKSPACES. The sum is then
n x ceiling <= pool by construction, with no ledger of outstanding
grants and no dependence on arrival order. Tested as an invariant across
four host sizes: fill the daemon to its admission limit and the
authorised total still fits.

The cost is deliberate and now documented rather than hidden: a lone
workspace on a 32 GB host gets 614 MB rather than the pool, because any
child may still be running when the house fills. An 8 GB host admits
seven concurrent children at 526 MB each.

Also from review:

- The policy is no longer built for an injected `deps.bridge`. That
  bridge carries its own channel and never reaches the factory the
  policy rides on, so status could report `enforced: true` while nothing
  was being sized.
- Both transport mappings now have direct tests. They are hand-written
  beside each other and drift silently; the spawn-policy tests cannot
  catch a wire regression.
- Swept the "does not size any child" claim, which enforce makes false,
  out of the CLI help text, ServeOptions docs, the two operator tables,
  and the e2e header comment. The 17-configuration table realigns
  wholesale because that cell was its widest — whitespace only.

Refs #8182.

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

* refactor(serve): model the child heap partition, defer applying it

Review established that the refusal counter cannot tell an operator
whether enforcement is safe, and that is the ground the enforcing mode
stood on. While observing, children run on the host-derived ceiling
(16384 MB on a 32 GB host), so a workload needing 2 GB of old space is
healthy with zero refusals and OOMs the moment a 614 MB partition is
applied. The counter measures admission pressure, not ceiling adequacy.

Rather than ship a switch with no safe way to decide when to turn it on,
`enforce` is removed. `--child-heap-mode` is `off | observe`, and the
mode that would apply the partition arrives with the measurement that
justifies it: peak old-space per child, compared against the modeled
ceiling. That is a real measurement chain — the child reports rss and
cpu today, and `--max-old-space-size` bounds old space specifically, so
neither rss nor heapUsed answers the question.

With nothing applying the partition, the machinery that existed only to
apply it goes too rather than shipping unreachable:
`getAcpMemoryArgs(explicitMb?)`, `ChildHeapPoolExhaustedError` and both
transport mappings, and `limits.memory.enforced` reverts to the required
literal `false` it was before. The spawn path is untouched again; the
factory asks the policy what it would decide purely so the count is
real.

Also fixes the zero-pool defect review found, which the removed clamp
caused: forcing at least one admissible child on a 512 MB host — where
the root reserve consumes the whole 256 MB budget — produced a ceiling
of 0, and `--max-old-space-size=0` is V8's *default* heap, not a zero
ceiling. A pool that cannot cover one child at the floor now reports
`maxConcurrentChildren: 0` and `perChildCeilingMb: null`, and the test
that enshrined the old behaviour is inverted.

Status now publishes `maxConcurrentChildren` and `perChildCeilingMb`, so
an operator can judge the partition against their own workload — the
substitute for a counter that cannot judge it for them. Every claim that
a zero refusal count means the partition is safe to apply is removed
from the flag help, the operator docs, the protocol doc, and the design
doc.

Refs #8182.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
doudouOUC added a commit that referenced this pull request Aug 5, 2026
Three findings review raised against #8508 after the partition became
observation-only, all still live on this branch now that it has merged.

The status assertion in `run-qwen-serve.test.ts` failed on head: it used
`toEqual` against `{ mode, refusals }` while the wire also carries
`maxConcurrentChildren` and `perChildCeilingMb`, so the suite was red at
217 passed / 1 failed. The local type restating the wire shape was short
the same two fields. Both are filled in, and the assertion stays `toEqual`
so an unannounced field still fails it — the two derived figures get
matchers because this suite boots a real daemon and the pool follows the
machine. What they have to satisfy is now pinned separately: a fixed
ceiling times the number admitted must fit inside the pool it partitions,
which is the whole reason the partition bounds anything.

`decide()` and `getAcpMemoryArgs()` ran between `reserve()` and the `try`
that cancels the reservation. `childHeapPolicy` is a public
`createSpawnChannelFactory` option, so `decide()` is caller code and may
throw; the spawn then rejected with the token held for the process
lifetime, inflating `committedProcessCount` for every later spawn. Both
calls move inside the `try`. The regression test is mutation-verified —
reverting the move gives `expected 1 to be +0`.

`ServeOptions.memoryBudgetMb` still promised a `childHeapMode: 'enforce'`
that sizes children and refuses spawns. No such mode exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
doudouOUC added a commit that referenced this pull request Aug 5, 2026
#8508)

* feat(serve): add the child-heap admission primitives, unwired

Groundwork for #8182 step 2. Nothing calls any of this yet, so no child
is sized differently and no spawn is refused.

`ProcessRegistry.committedProcessCount` counts attached children plus
reservations that have not attached. That is the figure admission has to
key on: `reserve()` inserts its token synchronously before `spawn()`, so
two racing spawns each see the other, while neither appears in
`activeProcessCount` until its child attaches. A child leaves the count
on exit rather than when `terminate()` starts, so a channel swap counts
twice while the old process winds down — deliberate, since its memory is
still resident.

`getAcpMemoryArgs(explicitMb?)` takes an optional share that bypasses
both the module cache and the raise-only guard. Both bypasses are
load-bearing. The cache, because the share depends on how many children
are live now rather than on the host. The guard, because a
budget-derived share is normally *below* the daemon's own heap limit, so
routing it through `targetMB > currentLimitMB` would drop the flag,
silently restore the overcommit, and leave every test green — the trap
against a multi-GB runner, and mutation-checking it by reinstating the
guard fails two tests.

`createChildHeapPolicy` holds the mode, the budget, and the would-be
refusal counter, and answers `decide(concurrentChildren)`. The refusal
is derived from the unclamped quotient, not from
`recommendedChildShareMb`, because that function clamps *up* to the
512 MB floor: past the point where the pool stops covering the count its
answer saturates and can no longer distinguish "barely does not fit"
from "wildly does not fit".

`ChildHeapPoolExhaustedError` with both transport mappings — REST 503
with Retry-After, ACP `child_heap_pool_exhausted` — added together,
since the two mappings are hand-written and drift silently otherwise.
Refusing at spawn rather than at registration is the correction #8182
demands: registration allocates nothing, so this surfaces as "no new
session in this workspace right now", which is true and retryable.

Refs #8182.

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

* feat(serve): size each ACP child by concurrently live children

Wires the primitives from the previous commit into the spawn path, behind
`--child-heap-mode off | observe | enforce`, default `observe`.

Under `enforce` a child's `--max-old-space-size` is a share of the child
pool divided by the children concurrently committed at the moment it
spawns — read from the shared ProcessRegistry after `reserve()`, so two
racing spawns each see the other. When the pool cannot cover another
child at the 512 MB floor the spawn is refused with
ChildHeapPoolExhaustedError, which is what turns a per-child ceiling into
an aggregate bound: concurrent children can never exceed pool/512.

Keyed on concurrency, never on registrations. A dormant workspace has no
child, so it costs nothing — the specific correction #8182 records
against the withdrawn proposal, which would have shrunk a lone live child
to 614 MB because of 24 idle registrations.

Default `observe` computes the share and the admission decision and
applies neither, counting the refusals that would have happened. The
divisor has never been checked against a real multi-workspace deployment,
and a non-zero count is how an operator learns enforcement would have
broken them without being broken. It also catches the case worth
worrying about: a channel swap counts the dying child alongside its
replacement, so on a saturated pool enforcement could refuse a restart
and leave that workspace with no child at all. Excluding terminating
children would authorise real overcommit to dodge a hypothetical
refusal, so the count reports it instead.

Ceilings already granted are not revisited — V8 cannot lower them — so
granted ceilings transiently exceed the pool. Acceptable: the flag is a
ceiling, not a reservation, and a workspace with no live sessions has no
child and picks up the current share on its next spawn.

`limits.memory.enforced` stops being a required literal `false`. #8245
made it one so a client could never mistake that namespace for
enforcement that had not shipped; it has now, so the field is a boolean
derived from the mode — and stays `false` under `observe`, which applies
nothing.

Refs #8182.

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

* docs(serve): correct the claims child-heap enforcement makes false

Two sentences in the protocol doc described the memory section as
unconditionally observational: "a required `enforced: false`", and "no
child spawn argument derives from these values, and no request is
refused on their basis". Both are false under `--child-heap-mode
enforce`, so both are rewritten rather than left to rot — `enforced` is
now documented as the boolean that answers exactly this, and the refusal
is documented with its wire shape on both transports.

Also documents `childHeap.refusals` as the calibration signal, since a
would-be-refusal count is useless if operators do not know to read it
before switching to `enforce`; the flag row in the three operator docs;
and the design doc's Part 1, which listed applying a share as a
compatibility risk without recording how that was resolved.

The end-to-end test asserts the policy reaches a real booted daemon's
status with `enforced: false` under the default mode — the wire type in
that test is a hand-written mirror, so its `enforced: false` literal had
to widen too, which is the check that caught the type not being widened
everywhere.

Refs #8182.

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

* test(serve): cover both branches of the enforced tripwire

`enforced` was only ever asserted false — the unit tests build no policy
and the end-to-end daemon runs the default `observe` mode, so the branch
that makes the field worth having was untested. Hardcoding it back to
`false` passed everything.

Also pins `childHeap: null` as distinct from a policy in `off` mode: the
first says no policy exists (direct-embed, or the bootstrap window before
the runtime is built), the second says one exists and computes nothing.

Refs #8182.

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

* fix(serve): partition the child pool so granted ceilings stay inside it

Review was right that the previous design did not deliver the aggregate
bound it claimed. Sizing each child by the count live at *its* spawn
bounds the child count but not the memory: V8 cannot lower a running
child's ceiling, so grants accumulate as P + P/2 + P/3 + ... = P x H(n).
Reproduced exactly — 9557 MB authorised against a 3687 MB pool at seven
children on an 8 GB host, and 61355 MB against 15360 MB at the limit on
32 GB. That is 2.6x and 4x the pool, which is what the policy exists to
prevent.

Grant accounting alone does not fix it: the first child would take the
whole pool and the second would be refused immediately. Keeping the
invariant requires early children not to receive the whole pool, so the
ceiling is now a fixed partition — childPoolMb / maxConcurrentChildren,
constant for every child, with maxConcurrentChildren itself derived from
the pool and capped at MAX_DAEMON_WORKSPACES. The sum is then
n x ceiling <= pool by construction, with no ledger of outstanding
grants and no dependence on arrival order. Tested as an invariant across
four host sizes: fill the daemon to its admission limit and the
authorised total still fits.

The cost is deliberate and now documented rather than hidden: a lone
workspace on a 32 GB host gets 614 MB rather than the pool, because any
child may still be running when the house fills. An 8 GB host admits
seven concurrent children at 526 MB each.

Also from review:

- The policy is no longer built for an injected `deps.bridge`. That
  bridge carries its own channel and never reaches the factory the
  policy rides on, so status could report `enforced: true` while nothing
  was being sized.
- Both transport mappings now have direct tests. They are hand-written
  beside each other and drift silently; the spawn-policy tests cannot
  catch a wire regression.
- Swept the "does not size any child" claim, which enforce makes false,
  out of the CLI help text, ServeOptions docs, the two operator tables,
  and the e2e header comment. The 17-configuration table realigns
  wholesale because that cell was its widest — whitespace only.

Refs #8182.

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

* refactor(serve): model the child heap partition, defer applying it

Review established that the refusal counter cannot tell an operator
whether enforcement is safe, and that is the ground the enforcing mode
stood on. While observing, children run on the host-derived ceiling
(16384 MB on a 32 GB host), so a workload needing 2 GB of old space is
healthy with zero refusals and OOMs the moment a 614 MB partition is
applied. The counter measures admission pressure, not ceiling adequacy.

Rather than ship a switch with no safe way to decide when to turn it on,
`enforce` is removed. `--child-heap-mode` is `off | observe`, and the
mode that would apply the partition arrives with the measurement that
justifies it: peak old-space per child, compared against the modeled
ceiling. That is a real measurement chain — the child reports rss and
cpu today, and `--max-old-space-size` bounds old space specifically, so
neither rss nor heapUsed answers the question.

With nothing applying the partition, the machinery that existed only to
apply it goes too rather than shipping unreachable:
`getAcpMemoryArgs(explicitMb?)`, `ChildHeapPoolExhaustedError` and both
transport mappings, and `limits.memory.enforced` reverts to the required
literal `false` it was before. The spawn path is untouched again; the
factory asks the policy what it would decide purely so the count is
real.

Also fixes the zero-pool defect review found, which the removed clamp
caused: forcing at least one admissible child on a 512 MB host — where
the root reserve consumes the whole 256 MB budget — produced a ceiling
of 0, and `--max-old-space-size=0` is V8's *default* heap, not a zero
ceiling. A pool that cannot cover one child at the floor now reports
`maxConcurrentChildren: 0` and `perChildCeilingMb: null`, and the test
that enshrined the old behaviour is inverted.

Status now publishes `maxConcurrentChildren` and `perChildCeilingMb`, so
an operator can judge the partition against their own workload — the
substitute for a counter that cannot judge it for them. Every claim that
a zero refusal count means the partition is safe to apply is removed
from the flag help, the operator docs, the protocol doc, and the design
doc.

Refs #8182.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
doudouOUC added a commit that referenced this pull request Aug 5, 2026
Three findings review raised against #8508 after the partition became
observation-only, all still live on this branch now that it has merged.

The status assertion in `run-qwen-serve.test.ts` failed on head: it used
`toEqual` against `{ mode, refusals }` while the wire also carries
`maxConcurrentChildren` and `perChildCeilingMb`, so the suite was red at
217 passed / 1 failed. The local type restating the wire shape was short
the same two fields. Both are filled in, and the assertion stays `toEqual`
so an unannounced field still fails it — the two derived figures get
matchers because this suite boots a real daemon and the pool follows the
machine. What they have to satisfy is now pinned separately: a fixed
ceiling times the number admitted must fit inside the pool it partitions,
which is the whole reason the partition bounds anything.

`decide()` and `getAcpMemoryArgs()` ran between `reserve()` and the `try`
that cancels the reservation. `childHeapPolicy` is a public
`createSpawnChannelFactory` option, so `decide()` is caller code and may
throw; the spawn then rejected with the token held for the process
lifetime, inflating `committedProcessCount` for every later spawn. Both
calls move inside the `try`. The regression test is mutation-verified —
reverting the move gives `expected 1 to be +0`.

`ServeOptions.memoryBudgetMb` still promised a `childHeapMode: 'enforce'`
that sizes children and refuses spawns. No such mode exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
qwen-code-dev-bot pushed a commit to dreamWB/qwen-code that referenced this pull request Aug 7, 2026
QwenLM#8423)

* feat(serve): observe daemon memory pressure against a real denominator

The daemon samples its own RSS and heap but has nothing to divide them
by, so nothing in `/daemon/status` says whether a figure is fine or
nearly fatal. QwenLM#8245 landed the denominator (`limits.memory`); this turns
it into a reading.

`runtime.memory.pressure` reports `level`, `ratio`, `source`, and the six
raw figures behind them. The level is the worse of two independent
ratios, because the two failure modes are independent: a container dies
by RSS against its cgroup limit, while a process on a large host can
exhaust V8's heap long before RSS is a meaningful fraction of the
machine. Reporting only one hides whichever failure the deployment is
actually heading for. `source` names which ratio produced the level, and
`unknown` says the daemon could not measure itself — which a consumer
must not read as healthy.

The denominator is `availableMemoryMb`, not `effectiveBudgetMb`: pressure
asks how close this process is to being killed, and what kills it is the
cgroup limit or host memory. An operator's budget is a policy number, so
classifying against it would report `critical` for a daemon in no danger.

`--memory-pressure-mode` is `off | observe`, default `observe`. Both
modes report every figure; only `observe` also raises the
`daemon_memory_pressure` warning, so `off` leaves the top-level `status`
rollup untouched — the thresholds are inherited from an interactive-CLI
monitor and are not yet calibrated for a long-running daemon, and a
deployment that alerts on `status` needs the reading without the verdict.
There is deliberately no `enforce`: nothing here remediates, and a value
a caller can pass but never use is a dead switch. It arrives with the
enforcement.

Scope is the daemon root process only. `childRssCoverage` still reads
`primary_only` and says so on the wire; aggregate child RSS and channel
workers are separate measurements and land separately.

Severity is `warning` at every level including `critical`, because
`error` would make `rollupStatus` return `error` for the whole daemon —
too strong a claim to stake on uncalibrated thresholds.

Refs QwenLM#8051.

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

* feat(serve): report aggregate ACP child RSS, not just the primary's (QwenLM#8462)

* test(serve): close the under-determined assertions review probed

The automated review mutation-probed this diff and found several
assertions that were live but under-determined — each mutant it names
kept the whole suite green. All confirmed locally, and all now fail:

- Deleting `level !== 'normal'` from the issue gate raised
  daemon_memory_pressure on a healthy daemon and flipped top-level
  status to warning on every response — the exact false positive
  `--memory-pressure-mode off` exists to opt out of. Now covered on both
  sides: nothing raised at a realistic denominator, exactly one warning
  at a denominator sized to land this process in `soft`.
- Summing children over `list()` instead of `listManaged()` dropped a
  draining-but-process-holding workspace while `activeAcpChildren` still
  counted it. The draining bridge now reports RSS, so the byte count can
  only come from that child.
- The message's denominator ternary had no coverage; inverting it sent
  an operator hunting RSS growth during a heap-driven incident.
- A truthiness guard on `ageMs` turned a measured-fresh reading (age
  exactly 0, when a status read lands in the sampler's millisecond) into
  `null`, which the field's own docs say never means fresh.
- The multi-contributor age test listed ages ascending, so a
  plain-overwrite accumulator produced the same answer as Math.max.
  Reordered descending, which kills last-wins and first-wins both.

Two declaration-only hunks — the issue-code union member and the
`pressure` field — were guarded by tsc alone, which vitest does not run.
Both are now pinned at runtime by asserting the code string and the full
key set.

Also fixes a real display defect: `toFixed(0)` renders a ratio of 0.795
as "hard at 80%", and 80% is critical's documented threshold. One
decimal, so the number and the level cannot contradict each other.

And corrects a JSDoc claim of mine that was simply wrong: `pressure` is
absent not only for direct-embed but on the bootstrap /daemon/status
route, which omits runtime.memory wholesale even though the budget is
resolved — and that window is not just startup, since a daemon whose
runtime fails to start serves the bootstrap app for its lifetime.

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

* refactor(serve): model a per-child heap partition of the daemon budget (QwenLM#8508)

* feat(serve): add the child-heap admission primitives, unwired

Groundwork for QwenLM#8182 step 2. Nothing calls any of this yet, so no child
is sized differently and no spawn is refused.

`ProcessRegistry.committedProcessCount` counts attached children plus
reservations that have not attached. That is the figure admission has to
key on: `reserve()` inserts its token synchronously before `spawn()`, so
two racing spawns each see the other, while neither appears in
`activeProcessCount` until its child attaches. A child leaves the count
on exit rather than when `terminate()` starts, so a channel swap counts
twice while the old process winds down — deliberate, since its memory is
still resident.

`getAcpMemoryArgs(explicitMb?)` takes an optional share that bypasses
both the module cache and the raise-only guard. Both bypasses are
load-bearing. The cache, because the share depends on how many children
are live now rather than on the host. The guard, because a
budget-derived share is normally *below* the daemon's own heap limit, so
routing it through `targetMB > currentLimitMB` would drop the flag,
silently restore the overcommit, and leave every test green — the trap
against a multi-GB runner, and mutation-checking it by reinstating the
guard fails two tests.

`createChildHeapPolicy` holds the mode, the budget, and the would-be
refusal counter, and answers `decide(concurrentChildren)`. The refusal
is derived from the unclamped quotient, not from
`recommendedChildShareMb`, because that function clamps *up* to the
512 MB floor: past the point where the pool stops covering the count its
answer saturates and can no longer distinguish "barely does not fit"
from "wildly does not fit".

`ChildHeapPoolExhaustedError` with both transport mappings — REST 503
with Retry-After, ACP `child_heap_pool_exhausted` — added together,
since the two mappings are hand-written and drift silently otherwise.
Refusing at spawn rather than at registration is the correction QwenLM#8182
demands: registration allocates nothing, so this surfaces as "no new
session in this workspace right now", which is true and retryable.

Refs QwenLM#8182.

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

* feat(serve): size each ACP child by concurrently live children

Wires the primitives from the previous commit into the spawn path, behind
`--child-heap-mode off | observe | enforce`, default `observe`.

Under `enforce` a child's `--max-old-space-size` is a share of the child
pool divided by the children concurrently committed at the moment it
spawns — read from the shared ProcessRegistry after `reserve()`, so two
racing spawns each see the other. When the pool cannot cover another
child at the 512 MB floor the spawn is refused with
ChildHeapPoolExhaustedError, which is what turns a per-child ceiling into
an aggregate bound: concurrent children can never exceed pool/512.

Keyed on concurrency, never on registrations. A dormant workspace has no
child, so it costs nothing — the specific correction QwenLM#8182 records
against the withdrawn proposal, which would have shrunk a lone live child
to 614 MB because of 24 idle registrations.

Default `observe` computes the share and the admission decision and
applies neither, counting the refusals that would have happened. The
divisor has never been checked against a real multi-workspace deployment,
and a non-zero count is how an operator learns enforcement would have
broken them without being broken. It also catches the case worth
worrying about: a channel swap counts the dying child alongside its
replacement, so on a saturated pool enforcement could refuse a restart
and leave that workspace with no child at all. Excluding terminating
children would authorise real overcommit to dodge a hypothetical
refusal, so the count reports it instead.

Ceilings already granted are not revisited — V8 cannot lower them — so
granted ceilings transiently exceed the pool. Acceptable: the flag is a
ceiling, not a reservation, and a workspace with no live sessions has no
child and picks up the current share on its next spawn.

`limits.memory.enforced` stops being a required literal `false`. QwenLM#8245
made it one so a client could never mistake that namespace for
enforcement that had not shipped; it has now, so the field is a boolean
derived from the mode — and stays `false` under `observe`, which applies
nothing.

Refs QwenLM#8182.

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

* docs(serve): correct the claims child-heap enforcement makes false

Two sentences in the protocol doc described the memory section as
unconditionally observational: "a required `enforced: false`", and "no
child spawn argument derives from these values, and no request is
refused on their basis". Both are false under `--child-heap-mode
enforce`, so both are rewritten rather than left to rot — `enforced` is
now documented as the boolean that answers exactly this, and the refusal
is documented with its wire shape on both transports.

Also documents `childHeap.refusals` as the calibration signal, since a
would-be-refusal count is useless if operators do not know to read it
before switching to `enforce`; the flag row in the three operator docs;
and the design doc's Part 1, which listed applying a share as a
compatibility risk without recording how that was resolved.

The end-to-end test asserts the policy reaches a real booted daemon's
status with `enforced: false` under the default mode — the wire type in
that test is a hand-written mirror, so its `enforced: false` literal had
to widen too, which is the check that caught the type not being widened
everywhere.

Refs QwenLM#8182.

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

* test(serve): cover both branches of the enforced tripwire

`enforced` was only ever asserted false — the unit tests build no policy
and the end-to-end daemon runs the default `observe` mode, so the branch
that makes the field worth having was untested. Hardcoding it back to
`false` passed everything.

Also pins `childHeap: null` as distinct from a policy in `off` mode: the
first says no policy exists (direct-embed, or the bootstrap window before
the runtime is built), the second says one exists and computes nothing.

Refs QwenLM#8182.

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

* fix(serve): partition the child pool so granted ceilings stay inside it

Review was right that the previous design did not deliver the aggregate
bound it claimed. Sizing each child by the count live at *its* spawn
bounds the child count but not the memory: V8 cannot lower a running
child's ceiling, so grants accumulate as P + P/2 + P/3 + ... = P x H(n).
Reproduced exactly — 9557 MB authorised against a 3687 MB pool at seven
children on an 8 GB host, and 61355 MB against 15360 MB at the limit on
32 GB. That is 2.6x and 4x the pool, which is what the policy exists to
prevent.

Grant accounting alone does not fix it: the first child would take the
whole pool and the second would be refused immediately. Keeping the
invariant requires early children not to receive the whole pool, so the
ceiling is now a fixed partition — childPoolMb / maxConcurrentChildren,
constant for every child, with maxConcurrentChildren itself derived from
the pool and capped at MAX_DAEMON_WORKSPACES. The sum is then
n x ceiling <= pool by construction, with no ledger of outstanding
grants and no dependence on arrival order. Tested as an invariant across
four host sizes: fill the daemon to its admission limit and the
authorised total still fits.

The cost is deliberate and now documented rather than hidden: a lone
workspace on a 32 GB host gets 614 MB rather than the pool, because any
child may still be running when the house fills. An 8 GB host admits
seven concurrent children at 526 MB each.

Also from review:

- The policy is no longer built for an injected `deps.bridge`. That
  bridge carries its own channel and never reaches the factory the
  policy rides on, so status could report `enforced: true` while nothing
  was being sized.
- Both transport mappings now have direct tests. They are hand-written
  beside each other and drift silently; the spawn-policy tests cannot
  catch a wire regression.
- Swept the "does not size any child" claim, which enforce makes false,
  out of the CLI help text, ServeOptions docs, the two operator tables,
  and the e2e header comment. The 17-configuration table realigns
  wholesale because that cell was its widest — whitespace only.

Refs QwenLM#8182.

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

* refactor(serve): model the child heap partition, defer applying it

Review established that the refusal counter cannot tell an operator
whether enforcement is safe, and that is the ground the enforcing mode
stood on. While observing, children run on the host-derived ceiling
(16384 MB on a 32 GB host), so a workload needing 2 GB of old space is
healthy with zero refusals and OOMs the moment a 614 MB partition is
applied. The counter measures admission pressure, not ceiling adequacy.

Rather than ship a switch with no safe way to decide when to turn it on,
`enforce` is removed. `--child-heap-mode` is `off | observe`, and the
mode that would apply the partition arrives with the measurement that
justifies it: peak old-space per child, compared against the modeled
ceiling. That is a real measurement chain — the child reports rss and
cpu today, and `--max-old-space-size` bounds old space specifically, so
neither rss nor heapUsed answers the question.

With nothing applying the partition, the machinery that existed only to
apply it goes too rather than shipping unreachable:
`getAcpMemoryArgs(explicitMb?)`, `ChildHeapPoolExhaustedError` and both
transport mappings, and `limits.memory.enforced` reverts to the required
literal `false` it was before. The spawn path is untouched again; the
factory asks the policy what it would decide purely so the count is
real.

Also fixes the zero-pool defect review found, which the removed clamp
caused: forcing at least one admissible child on a 512 MB host — where
the root reserve consumes the whole 256 MB budget — produced a ceiling
of 0, and `--max-old-space-size=0` is V8's *default* heap, not a zero
ceiling. A pool that cannot cover one child at the floor now reports
`maxConcurrentChildren: 0` and `perChildCeilingMb: null`, and the test
that enshrined the old behaviour is inverted.

Status now publishes `maxConcurrentChildren` and `perChildCeilingMb`, so
an operator can judge the partition against their own workload — the
substitute for a counter that cannot judge it for them. Every claim that
a zero refusal count means the partition is safe to apply is removed
from the flag help, the operator docs, the protocol doc, and the design
doc.

Refs QwenLM#8182.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(serve): repair the child-heap assertion and the reservation leak

Three findings review raised against QwenLM#8508 after the partition became
observation-only, all still live on this branch now that it has merged.

The status assertion in `run-qwen-serve.test.ts` failed on head: it used
`toEqual` against `{ mode, refusals }` while the wire also carries
`maxConcurrentChildren` and `perChildCeilingMb`, so the suite was red at
217 passed / 1 failed. The local type restating the wire shape was short
the same two fields. Both are filled in, and the assertion stays `toEqual`
so an unannounced field still fails it — the two derived figures get
matchers because this suite boots a real daemon and the pool follows the
machine. What they have to satisfy is now pinned separately: a fixed
ceiling times the number admitted must fit inside the pool it partitions,
which is the whole reason the partition bounds anything.

`decide()` and `getAcpMemoryArgs()` ran between `reserve()` and the `try`
that cancels the reservation. `childHeapPolicy` is a public
`createSpawnChannelFactory` option, so `decide()` is caller code and may
throw; the spawn then rejected with the token held for the process
lifetime, inflating `committedProcessCount` for every later spawn. Both
calls move inside the `try`. The regression test is mutation-verified —
reverting the move gives `expected 1 to be +0`.

`ServeOptions.memoryBudgetMb` still promised a `childHeapMode: 'enforce'`
that sizes children and refuses spawns. No such mode exists.

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

* fix(serve): report no child-heap partition under `off`

`snapshot()` returned `maxConcurrentChildren` and `perChildCeilingMb`
unconditionally, so a daemon run with `--child-heap-mode off` still
published a partition — 7 children at 526 MB on an 8 GB host — under a
mode whose documentation says "do not model it". Review raised it, and it
mattered more than it looked: with `enforce` gone, `off` and `observe`
differed only in whether `refusals` incremented, so nothing on the wire
distinguished a model that was switched off from one in force.

Both figures are now `null` under `off`, which required widening
`maxConcurrentChildren` to `number | null` in the daemon type and the SDK
mirror. `null` rather than `0`: zero is already the computed answer for a
pool too small to host one child at the 512 MB floor, and collapsing the
two would tell an operator who disabled the model that their host cannot
run anything. That leaves three distinguishable states — no policy at all
(`childHeap: null`), a policy modeling nothing (`mode: 'off'` with null
figures), and a live model — and each now has a test.

The `off` unit test previously asserted only `refusals`, so its name
("models nothing at all when off") promised more than it checked. It now
covers the figures, with a sibling test pinning 7 / 526 under `observe` on
the same budget so nulling them unconditionally cannot satisfy both.
Mutation-verified in both directions.

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

* fix(serve): never model a child heap ceiling below the documented minimum

`perChildCeilingMb` is `min(floor(pool / maxConcurrentChildren),
legacyChildCeilingMb)`. The first term is at least `MIN_CHILD_HEAP_MB` by
construction; the second is `floor(available / 2)` and is not, so the
`Math.min` could publish a ceiling *below* the `minChildHeapMb` sitting beside
it in the same snapshot:

    avail=768  --memory-budget-mb 1024  pool=512  legacyCeil=384  perChild=384
    avail=1023 --memory-budget-mb 1024  pool=767  legacyCeil=511  perChild=511

Unreachable from a derived budget — the pool reaches 0 first — but an explicit
budget has a floor of 1024 while available memory does not, and
`docs/users/qwen-serve.md` tells operators on exactly these hosts to pass that
flag. The documented remedy is what reaches the band.

Refuse the model rather than shrink under the floor, with
`maxConcurrentChildren` zeroed in lockstep: a ceiling no child may run at is
not a partition, and "one child fits" beside a null ceiling is the same
contradiction from the other side. Nothing is applied today so the impact was a
wrong published figure, but this is the number the partition asks to be judged
by and the one an `enforce` mode would hand to `--max-old-space-size`.

The existing matrix resolves derived budgets only, which is why the mutation
sweep came back clean; add the `budgetMb` axis, asserting in each case the
shape that makes it reachable, and pin the inclusive boundary (1024/1024 ->
one child at 512) so nulling unconditionally cannot pass instead.

Also, in the same review pass:

- Split usable-gauge handling into numerator and denominator. Coercing an
  unusable numerator to 0 published `rssBytes: 0, rssRatio: 0, level: 'normal',
  source: 'rss'` — a daemon that measured nothing, indistinguishable from an
  idle one, which is the confusion `source: 'unknown'` and `sampled: 0` exist
  to prevent everywhere else here. An unusable numerator now retires its own
  side. Zero stays a reading for a numerator and not for a denominator.
- Document that `rssRatio` divides by host total under
  `availableMemorySource: 'host'`, so it is a lower bound on real pressure
  there — a denominator problem no threshold calibration addresses.
- Document that `refusals` counts channel swaps at full occupancy (the
  terminating child is counted until it exits) and equals the total spawn count
  on a host too small to model a partition. Deliberately not fixed by giving
  the comparison swap headroom, which would admit a 26th ceiling against a
  25-child pool.
- Keep the sampler's rejection handler as a documented backstop — the shipped
  `refreshChildResource` never rejects, but it is an optional `async` interface
  member, so a foreign implementation throwing early would otherwise surface as
  an unhandled rejection — and give it the workspace so it is attributable
  across the fan-out.
- Test hygiene: drop a duplicated `enforced` assertion; replace a host-
  dependent `expect.any(Number)` with a key-set pin plus a branch, since a
  small host now legitimately reports no partition; use `vi.spyOn(Date, 'now')`
  over direct assignment; reuse the exported `ChildHeapMode` on the child-heap
  side, leaving the independent `memoryPressureMode` switch alone.

Reported by @wenshao.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants