Skip to content

fix(quota): charge what the backend really caps, and name every limit that refused - #712

Open
HYChou0515 wants to merge 8 commits into
masterfrom
worktree-sandbox-effective-cost
Open

fix(quota): charge what the backend really caps, and name every limit that refused#712
HYChou0515 wants to merge 8 commits into
masterfrom
worktree-sandbox-effective-cost

Conversation

@HYChou0515

@HYChou0515 HYChou0515 commented Aug 12, 2026

Copy link
Copy Markdown
Owner

額度的 cpu/memory 用量恆為 0,而被拒絕時只會被告知一個上限。修這兩件,以及三輪對抗式 review 從修正本身挖出來的東西。


一、用量(分子)恆為 0

registry._bump_activityspec.cpu_cores or 0 計費。App 沒宣告 resourcesspec.cpu_coresNone——但 None 的意思是「後端請用你自己的天花板」,而每個真實後端都真的會套(本機 sandbox.isolation.*、正式 SANDBOX_HOST_*,都是 1 core / 512M)。所以沒宣告的 App 免費佔著一顆核心,而 per-user 的 cpu/memory 上限加總全是 0、永遠不生效。

做法:由套用的那一方回報。 跟這個 repo 既有的磁碟規則同形(the sandbox measures, the facade enforces)。

  • Sandbox.effective_limits(spec) —— 把請求裡的每個 None 換成這個後端真正會套的數字。完全不設 cgroup 的後端(mock、純 local process)回報 None,就真的不計費。
  • isolated 後端直接問自己的 _CgroupManager,所以計費的數字就是寫進 cpu.max 的那個
  • http 後端讀不到另一個服務的環境變數,所以由 host 在 /healthz 公告(同樣是問 sandbox 自己,不是重讀 settings),client 快取。舊 host 沒有這個欄位就維持今天的行為,並由既有的版本探針指出映像過舊——看得見的少算,不是憑空猜

准入閘門也讀同一個來源:admission.check 過去在唯一的正式呼叫點從來沒傳 incoming,所以「即將開的這一個」一律當成免費,cpu/memory 只擋得住已經超標的人——擋不住「再開一個就超標」,而那正是准入控制的全部用途。

二、被拒絕時只被告知一個上限

檢查是同樣兩行複製在兩個入口點,順序固定。兩個都滿的人只被告知磁碟,刪完重送才知道還卡在環境數——每則訊息都是真的,是那個順序讀起來像 bug。收斂成 turn_gate.admit_turn:兩個 gate 都跑,detail.also 一次講完,只讀 error 的 client 行為不變。措辭也從四份 body literal 收成一個 quota_body

三、訊息帶上數字

「這個項目的工作區空間已滿」對讀的人無法否證——錯的那則跟對的那則長得一模一樣。現在接上「(已用 X,上限 Y)」,而且環境上限會講清楚是哪個維度:它是三個限制共用一句話,直接掛「已用 4,上限 2」會讓只開 1 個環境的人以為自己開了 4 個。


三輪 review 從修正本身挖出來的

每一輪最嚴重的一格:①「修正把原缺陷種回去」→ ②「修正引入正式環境才會爆的缺陷」→ ③「保證沒有測試守著」。

round 1 —— HttpSandbox失敗永久快取:一次 /healthz 抖動就把該 pod 釘在「每個 sandbox 免費」,正是本 PR 要修的缺陷,只是變無聲、且要重啟才好。換掉機制:只快取答案、加 TTL(host 是獨立部署,rolling 它不會重啟 app)、加鎖、逾時 10s→3s。同輪還發現:開機警告仍在對維運講已被推翻的舊規則,而兩條互相矛盾的綠測試各自守著一半;以及正式環境真正算出那個數字的 host 程式碼零測試覆蓋(改成回 (999, 1) 仍 202 passed)。

round 2 —— 我在 round 1 的修正裡種了兩個新缺陷,而且是同一個形狀:正式環境才有的接線,被替身抹掉了

  • 開機那行 asyncio.run 對 host 發真請求,keep-alive 連線留在池裡、綁在隨即關掉的 loop 上 → 伺服 loop 上第一個 sandbox 動作必爆 Event loop is closed;落在計費路徑被吞成「算 0」,落在 create/exec不映射不重試,直接 500。已搬到 lifespan(抄既有的 prewarm_tools 前例)。
  • 「收集所有拒絕理由」讓被拒絕的寫入去扣別人的額度:per-person gate 不是純判斷,放行時會寫 ledger。一次被拒的資料夾 copy 把 owner 帳本推高,她於是在另一個無關的 item 被擋,而那個數字在整個產品裡不存在。record 現在是契約的一部分。

round 3 —— 我 9 句「這件事不可能再發生」裡,6 句刪掉強制力之後全綠(而我的 commit 訊息寫著「each with a test or probe」)。六條都補上會紅的測試,包括讓假 host 真的讓出 event loop,否則測試根本量不到那個鎖。同輪還發現我宣稱修好的「開機說謊」換一扇門回來:HttpSandbox 把「問不到」和「host 說它什麼都不管」壓成同一個值,所以我加的「沒問到就不警告」分支永遠走不到。改成刪掉死分支 + 讓訊息在 host 不可達時也為真(「這行分不出兩者,等它起來再看一次」)。

docs/plan-sandbox-resource-quota.md §3.3 記錄了為什麼原計劃全部打勾之後還有這一堆洞:每條流量維度的驗收條件都只驗了 count、驗收用的設定剛好遮住缺陷(demo 的 App 有宣告資源,而 repo 裡四個都沒有)、「按得動」被當成「做完了」、整份計劃只從被拒絕的使用者視角寫。以及下一份計劃的四條寫法。


驗證(全部重跑取得,非引用)

  • 後端 tests/api + quota + workflow + sandbox + health + files -m "not integration" -n auto2351 passed / 3 xfailed
  • sandbox-host → 206 passed / 2 skipped;兩個套件各自ruff check + ruff format --check(root 的 ruff 不涵蓋 sandbox-host/)
  • ty check 全過;mkdocs build --strict exit 0
  • 前端 → 348 檔 / 2897 tests,vitest exit 0,tsc --noEmit 乾淨

紅→綠都真的紅過,而且紅的理由就是缺陷本身:

測試 修之前
test_a_live_environment_is_charged_what_the_backend_really_caps_it_at assert 0.0 == 2.0
test_a_refusal_names_every_limit_that_is_at_its_cap 拿到 workspace_quota_exceeded,而那人同時卡在環境數
test_cpu_alone_refuses_a_turn_when_no_app_declared_anything assert 202 == 507 —— 根本沒擋
host 的 test_what_the_host_publishes_is_what_it_writes_to_the_cgroup 突變成回傳常數 → 2 failed(reviewer 另外試了 7 種錯法,全被抓)
test_concurrent_first_calls_ask_once_and_a_failure_cannot_win 拿掉鎖 → assert 5 == 1

⚠️ 部署

要重新 build 並推 sandbox-host,否則正式環境的 http backend 拿不到 defaults,計費維持今天的少算(app 不會壞,/health 的探針會說映像過舊)。

順手發現,不在本 PR 修

sandbox-host 的 CI 型別閘 從來沒有檢查過任何東西:uv run ty check src tests(以及 ./src ./testssrc/sandbox_host)都印 WARN No python files found under the given path(s) 然後 All checks passed!。既有問題,不是本 branch 造成;不帶參數則吐 1215 個診斷,代表設定本身要修——那是獨立的一件事。

🤖 Generated with Claude Code

https://claude.ai/code/session_01LJagZZoJ8jKF7QsQAM56tC

`/my-resources` reported "CPU 0" next to a live environment, and a per-person
cpu/memory cap could never bind.

The tally read the SPEC. `None` there means "not stated — backend, apply your
own ceiling", and every real backend does: a cgroup at `SANDBOX_HOST_CPU_CORES`
/ `sandbox.isolation.*`. So an App that declared nothing held a core for free,
and `_SUMMED_DIMENSIONS` summed zeros.

The number now comes from the side that applies it, which is the same rule the
workspace quota already follows (the sandbox measures, the facade enforces):

- `Sandbox.effective_limits(spec)` — each `None` replaced by what this backend
  will really enforce. A backend that caps nothing (mock, plain local process)
  returns `None`s and nothing is charged, which is the honest answer rather
  than an invented one.
- The isolated backend answers from its own `_CgroupManager`, so the number
  charged is the number written to `cpu.max` — one rule, not a second copy.
- The http backend cannot read another service's environment, so the HOST
  advertises what it applies on `/healthz`, and the client caches it. A host
  too old to say leaves the request's `None`s in place — today's behaviour,
  a visible under-count rather than a guess — and `resource-defaults` joins
  `REQUIRED_CAPABILITIES` so the staleness probe names the stale image.

Also: the admin override form picks its target from the company directory
(`UserPicker`, the one share/@mention already use) instead of taking a typed
id. A mistyped id saved perfectly well, showed up in the exceptions list, and
bound to nobody.

Deploying this needs sandbox-host rebuilt and rolled out; until then the app
charges what it charges today.
The wire doc listed `/healthz` as `{status, version, capabilities}` and §6.5
explained the LIMIT without ever saying how the USAGE beside it is measured —
which is the half that was wrong, and the half whose answer depends on which
sandbox backend is deployed.
`ruff format` at the repo root does not reach `sandbox-host/` — it is a separate
package with its own config, and CI lints it as its own job. Same shape as the
miss on #709: I verified with a subset of what CI actually runs.
Two rules, written twice, reporting one answer.

The check was the same two lines copied into `chat_send.send` and
`workflow_exec.drive_turn`, run in a fixed order — disk, then environments — so
only the first to fire was ever reported. Someone both out of disk and at their
environment limit is told to delete files, deletes them, sends again, and is
told something different. Every message in that sequence is true; the sequence
is what reads as a bug, because the first one implied that acting on it would
let the turn through.

`turn_gate.admit_turn` is that check, once. It runs BOTH gates and raises
`TurnRefused` when more than one bound — costing one extra measurement on a path
that is already failing, and buying an answer the person can act on in a single
pass. The environment leads when both bind: closing one is a single click on a
page the refusal links to, while freeing disk means going into the item and
choosing what to lose.

The WORDING was duplicated the same way — a body literal per exception handler,
which is how the 507 phrasing drifted across four entry points once already. The
three handlers now share one, and `quota_body` builds the body for both them and
the combined refusal, so a fourth entry point cannot invent a fourth spelling.

On the wire: `detail.also` lists the other codes. A client that only reads
`error` behaves exactly as before; the FE appends one clause per extra limit
(`resources.also.*`), and those clauses name `/my-resources` so the refusal is
still clickable when the environment limit is the one that did not lead.
@HYChou0515 HYChou0515 changed the title fix(quota): charge a sandbox what the backend really caps it at fix(quota): charge what the backend really caps, and name every limit that refused Aug 12, 2026
…hat catch this

Writing the acceptance condition P5 never had — "cpu ALONE refuses, for an App
that declares nothing" — found the other half of the same defect, still live
after P1: the turn was admitted (202), not refused.

`admission.check(item_id)` was called with no `incoming` at the one production
call site, so the sandbox about to be opened was weighed as free. `cpu` and
`memory` could then only refuse someone ALREADY over their limit, never the
"one more would put you over" case that is the whole point. The gate's own
tests stayed green throughout because they feed the ledger by hand
(`store.bump(cpu_milli=1000)`) — they prove the GATE, not the pipeline that
fills it. A green unit test says nothing about whether its input is right.

`cost_of=registry.would_cost` makes the gate ask for that number itself, from
the same source the ledger charges from, so "does one more fit?" and "what is
already held?" cannot answer in different units — and no caller can forget to
pass it. A rule that can be forgotten is a suggestion.

docs/plan-sandbox-resource-quota.md §3.3 records why the plan passed while all
of this was broken: every flow-dimension condition was written with `count`,
the demo config declared resources (hiding the undeclared case), "pressable"
stood in for "legible", and the operator's view was never in scope. Plus the
four rules for writing the next plan's conditions, and the conditions P5/P8
should have had.
Four reviewers (defect / conformance / veracity / regression) ran in parallel.
The worst finding was in P1's own fix.

**The failure was cached for ever.** `HttpSandbox` asked the host once; when
that one call failed it remembered `(None, None)` — so a single `/healthz` blip
pinned the pod to "every sandbox is free", which IS the defect P1 exists to fix,
now silent and curable only by restarting. The staleness probe stays green
throughout: the host is fine, it was this side that gave up. Reproduced.

Replaced rather than patched, because the mechanism was the problem: cache only
ANSWERS, expire them (the host is a separate deployment — rolling it does not
restart the app, so "a redeploy is picked up" needed a TTL to be true), and hold
a lock (racing cold callers each wrote the cache, so a failing probe could
overwrite an answer the host had already given correctly). Timeout 10s → 3s: it
sits in front of a turn, before the message is persisted.

**The old rule was still being printed to operators.** P1 moved the criterion
from "did an App declare a cost" to "does the BACKEND enforce one", and five
places still stated the old one — including a boot line telling operators that
`per_user.cpu` "never fires" for exactly the configuration in which P5's new
test proves it refuses a turn. Two green tests asserting opposite things about
one config. `warn_unenforceable_dimensions` now takes what the backend enforces
(a REQUIRED argument — the four call sites that forgot it failed to compile,
which is the point) and runs after the sandbox exists.

**The production number had no test.** Mutating the host's `_CgroupManager`
to return garbage left `sandbox-host` at 202 passed: the one new test fed a
`MockSandbox` that echoes whatever constant you hand it. Now asserted against
the files `create` actually writes to `cpu.max` / `memory.max`, over every
stated/unstated combination — a version of the test a wrong `effective()`
cannot pass. Verified by mutation: 2 failed, restored, 205 passed.

Also, all found by review and each with a test or probe:

- The numbers appended to a refusal contradicted its sentence: "you are at your
  limit for live environments (4 used of 2)" to someone holding ONE — those were
  cores. The dimension is named now; a number that argues with its own sentence
  is worse than none, which was the entire reason for adding numbers.
- "Names every limit at its cap" was false for the likeliest pair: both disk
  rules live behind one facade call and the first short-circuited the second.
  `room_refusals` returns all of them; `ensure_room_for` still raises the first,
  which is what a write that is stopping anyway wants.
- A per-person cap below ONE sandbox's cost refuses everyone's FIRST environment
  while telling them to close one they do not have. Named at boot.
- `cost_of` was optional, so "no caller can forget" was false at the
  constructor. Required now.
- The fourth spelling of the 507 body already existed (`file_routes`); it goes
  through `quota_body` too.
- `/healthz` could 500 — it is the deployment's livenessProbe, so a backend that
  cannot answer a resource question must degrade, not crashloop.
- `resources.also.environment` was a message the backend can never produce (the
  environment limit always leads), and `QUOTA_REFUSALS` had no callers. Deleted.
- A second `formatBytes` rendered the same number differently from the
  `lib/bytes` one beside it. Merged back.
- The admin field's `<label>` pointed at nothing once the input became a picker.
- A headless refusal counted the other limits instead of naming them — and that
  is the one surface with no frontend to read `also`.
The round-1 fixes were reviewed. Two of them had introduced defects, and the
reviewer's sharpest output was that both are ONE shape: production wiring that
every test erases with a double.

**The boot probe poisoned the production connection pool.** P6 added
`asyncio.run(sandbox.effective_limits(...))` in `__main__` to ask the backend
what it enforces. For `kind: http` that is a real request: the keep-alive
connection it leaves in the client's pool belongs to a loop that is then closed,
so the FIRST sandbox call on uvicorn's loop dies with "Event loop is closed" —
swallowed into "charge nothing" on the accounting path, and unmapped on every
other (`RuntimeError` is neither `TimeoutException` nor `TransportError`, so
`_request` gives it no retry and no translation: a 500).

Invisible to the whole suite, which injects a client and never leaves one loop.
It now runs in the LIFESPAN, beside `prewarm_tools` — the existing precedent for
"boot work that touches the sandbox" — on the loop that will serve.

**Gathering reasons charged people for writes that never happened.** The
per-person disk gate is not a predicate: on the allowed path it records the
post-write size. `ensure_room_for` never reached it once the workspace rule had
refused; P6's `room_refusals` deliberately keeps going, and so reached a
RECORDING gate for an operation already refused. A refused folder copy moved the
owner's ledger 150 → 300, and they were then refused in a different item against
a number that appears nowhere in the product — the file tree still showed 150.
The gate's own comment says a refused write is "deliberately NOT recorded".
`record` is part of the contract now; the turn gate asks without charging.

Both fixes' tests used a double that only DECIDES (or never touches the
network), which is exactly why both defects shipped green. The new tests model
the side effect.

Also from the same round, in the ten lines P6 replaced:

- The lock serialised the FAILURE path — a failure is deliberately not cached,
  so twenty concurrent callers each paid a full 3s timeout in turn, in front of
  the pre-persist gate. A failure is now remembered briefly: long enough that no
  queue forms, short enough that recovery stays bounded.
- TTL expiry discarded a known-good answer, so a host that answered and THEN
  went down charged every sandbox zero — worse than one never asked. A stale
  ceiling now beats no ceiling.

And `_RoomAlways`, the turn-resilience double, fell behind `WorkspaceFiles`
twice in this branch. Its signature now mirrors the real one.
Nine sentences in this branch say some version of "this cannot happen again".
Six of them survived having their enforcement deleted: everything stayed green.
The P6 commit message claimed "each with a test or probe" for four of those.

Each now has a test that goes red when the mechanism is removed — verified by
deleting it, not by reading:

- the lock around the host-defaults fetch (the fake host now yields, because a
  transport that never suspends made the lock unmeasurable: five racing callers
  serialised themselves by accident and removing it changed nothing)
- both disk rules being named, not just the first (the only `also` test paired
  the environment limit with a disk one — two different gates; the pair that
  short-circuited lives inside one method)
- a combined refusal NAMING the other limits in its text, which is the whole
  message a scheduled run records (nothing referenced `TurnRefused` anywhere)
- the streamed upload's 507 going through `quota_body` rather than a fifth
  hand-written body
- the admin field having an accessible name
- a refusal's numbers being formatted by the same function as the usage bar

And the boot warning I claimed to have fixed came back through another door.
`HttpSandbox` reports an unreachable host as "enforces nothing" — the same value
as a host that genuinely caps nothing — so the `enforced=None` branch I added for
"we could not ask" was unreachable from the only production caller, and an
unreachable host still printed the false line. The dead branch is gone and the
wording now carries the ambiguity instead: it says the check cannot tell the two
apart and to look again once the host is up. Not knowing is a thing worth
saying; pretending to know is what this whole branch keeps being about.

Also: `docs/plan-sandbox-resource-quota.md` still stated the retired rule in two
places — one of them inside a hunk P6 itself moved. Marked as overturned rather
than quietly deleted, so the reversal is legible.
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.

1 participant