Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions docs/design/2026-07-31-daemon-capacity-model-and-memory-bounds.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,23 @@ The real control is admission at spawn time keyed on concurrently live children,
- **It must never raise a ceiling.** Clamping to `legacyChildCeilingMb` is what makes the policy safe to apply unconditionally; without it the minimum-budget constant and an over-large explicit flag both inflate the share.
- **The spawn path has a trap.** `getAcpMemoryArgs()` emits `--max-old-space-size` only when its computed target exceeds the _spawning daemon's own_ `heap_size_limit` (`spawnChannel.ts:27-34`). A budget-derived share is normally below that, so a naive change is silently dropped and the overcommit returns. The regression test must assert the flag survives a value below the test process's own limit.

**Modeled, not yet applied, as `--child-heap-mode`.** A first attempt sized each child by the count live at _its_ spawn; review showed that bounds the child count but not the memory, since V8 cannot lower a running child's ceiling and grants accumulate as P x H(n) — 2.6x the pool at seven children on 8 GB. The model is now a fixed partition: one constant ceiling for every child, with admission capped so the total stays inside the pool by construction.

Applying it is deliberately deferred. The compatibility point above is why: enforcing changes child GC and OOM behaviour, and nothing yet tells an operator beforehand whether their workload fits the ceiling. The refusal count cannot — children run on the host-derived ceiling while observing, so it measures admission pressure, not ceiling adequacy. The enforcing mode ships with the measurement that justifies it: peak old-space per child, compared against the modeled ceiling.

### Part 2 — Observe, with a denominator, before enforcing

The existing five-second sampler gains the effective memory limit, `v8.getHeapStatistics().heap_size_limit`, and aggregate child RSS across **all** workspace children and channel workers rather than the primary alone. Status gains `runtime.memory { level, ratio, source }` and two codes on the closed issue union at `daemon-status.ts:70-85`.
This part splits by what each piece measures, because the denominators are independent and the cheap one is worth landing first.

**Ships first — the daemon root against its own two limits.** Status gains `runtime.memory.pressure`, carrying `level`, `ratio`, `source`, and the six raw figures the ratios come from, plus one code — `daemon_memory_pressure` — on the closed issue union. `source` names which denominator produced the level: RSS against detected cgroup/host memory, or V8 heap used against `getHeapStatistics().heap_size_limit`. Both are needed, because a container dies by the first and a process on a large host exhausts the second long before RSS is a meaningful fraction of the machine. It reads `process.memoryUsage()` where the status response is built rather than extending the sampler: the reading is wanted per status request, not per five-second tick, and the sampler's ring is a separate consumer that can be fed once there is something to trend.

**Landed second — aggregate child RSS.** The feared second failure mode (a child that exits mid-poll) turned out to need no new mechanism: the per-bridge cache already drops a reading when the channel dies, and the same `isChannelLive()` predicate removes that workspace from both the sum and the count in one synchronous pass, so numerator and denominator stay consistent. `childRssCoverage` now reads `active_children` and `runtime.memory.children` carries the sum with a `sampled` count beside it.

**Still deferred — the rest of the tree.** Channel workers report no RSS at all today, so covering them means building the reporting path first; the children's own MCP descendants are invisible for the same reason, since each child self-reports only its own process. Neither figure is process-tree memory, and the response says so.

An earlier draft of this section also promised a second issue code for a stale observation. It is not in the first change: nothing in it can produce a stale reading, since the figures are sampled synchronously as the response is built. A code with no reachable producer is an unwritable test and a contract clients would handle for nothing. It arrives with the polled measurements that can actually go stale.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R3-14: This paragraph is contradicted by what this same PR ships. 'nothing in it can produce a stale reading, since the figures are sampled synchronously as the response is built' — but runtime.memory.children reads cached poll results up to STALE_CHILD_RESOURCE_MS (~30s) old, which is exactly why the diff adds ageMs and oldestReadingAgeMs. The closing promise 'It arrives with the polled measurements that can actually go stale' is broken by this PR itself: those polled measurements landed here, and the issue code did not. — Failure scenario: this design doc is the stated reference for the deferred enforce mode; a future implementer records two false beliefs — that every shipped runtime.memory figure is sampled per status request, and that the stale-observation code still waits on some future polled measurement — when the shipped children block is already that measurement. Suggested fix: rewrite to match what shipped — pressure is sampled synchronously per request, but the aggregate child RSS reads cached poll results persisting up to ~30s (disclosed via oldestReadingAgeMs); the second issue code remains deferred, with the real reason stated.

中文说明

该段落与本 PR 自身发布的内容矛盾。「nothing in it can produce a stale reading, since the figures are sampled synchronously as the response is built」——但 runtime.memory.children 读取的是最长可达 STALE_CHILD_RESOURCE_MS(~30s)的缓存轮询结果,这正是 diff 新增 ageMs 与 oldestReadingAgeMs 的原因。结尾的承诺「It arrives with the polled measurements that can actually go stale」被本 PR 自己打破:那些会过期的轮询测量已经落地,而 issue 代码没有。失效场景:该设计文档是延迟的 enforce 模式明示的参考;未来实现者会记下两个错误认知——以为每个已发布的 runtime.memory 数值都是每次 status 请求现采的、以为 stale-observation 代码仍在等待某种未来的轮询测量——而已发布的 children 块就是那个测量。建议:改写为与实际发布一致——pressure 每次请求现采,但 children 汇总读取最长保留 ~30s 的缓存轮询结果(经 oldestReadingAgeMs 披露);第二个 issue 代码仍延迟,并写明真实原因。

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


The mode flag follows the established `--mcp-client-budget` / `--mcp-budget-mode` idiom: `off | warn | enforce`, defaulting to `warn` when a budget is set, with `enforce` rejected at boot until a later change earns it. Nothing in this part remediates.
The mode flag exposes only `off | observe`, defaulting to `observe`. An earlier draft borrowed the `--mcp-budget-mode` triple and offered `enforce` with a boot-time rejection, which is a dead switch: a value a caller can pass but never use. The enforcing value arrives with the enforcement. Nothing in this part remediates — no forced GC, no eviction, no session closure, no process termination.

This is deliberately promoted ahead of the byte-cap work. It is the only piece whose value does not depend on the rest of the design being correct, and every limit chosen later should be calibrated against its data rather than guessed. #8093's limit table is a weaker argument for this ordering than it first appears, and the weaker form is the honest one: `prompt: 384 MiB` is exactly `normalAdmissionBytes` and therefore redundant, but the 256 MiB categories are _not_ dead — a single category reaching 256 MiB binds well before total normal usage reaches the 384 MiB ceiling. The problem with the table is simply that the constants are uncalibrated, which is what observation fixes.

Expand Down
Loading
Loading