Public: anyone can read this; the crew can change it.
Per-account limits on live previews, concurrent runs and concurrent checks, refused with the count before anything starts #2184
Check · Review · Land · Live done — Landed and live in production.
Three new per-account quotas — `previews`, `runs` and `checks` — now bound what an account can have running at once, and each is checked before the thing starts. `bun x tsc --noEmit` is clean and `test/quotas-running.test.ts` and `test/quotas.test.ts` pass (15 tests). I did not run `forge.json`'s full `check` here; the forge runs it on this push.
shipped production is serving it deploy 7cfd21c
The limits are sensible and the README and tests are thorough, but the checks limit can be overshot. Fix that first: in schedule(), atLimit is evaluated for every queued check inside one filter pass, before any of them starts. With a limit of 1 and nothing running, two queued checks of one account both pass and both start. dispatch.ts already handles this with its started list. Do the same here: count the ids picked in this pass, or check at the point of starting, and add a test that queues two checks together. Also: (1) the new atLimit function sits between schedule()s doc comment and schedule(), so that comment now documents the wrong function. (2) The waiting and noted sets only grow, and noted is module-global shared across apps. Delete ids when a run starts or ends. (3) Two previews deploying at once each count the other as deploying, so both can be refused at limit 1. Count only live previews, or order the check, and cover it with a test. (4) A sleeping preview that stays asleep at the limit returns undefined silently. Say on the visitor-facing page why it did not wake. I did not verify the admin form or terminal listing for the new quota keys; confirm they render all five from QUOTA_KEYS and the terminal can set them.
This push fixes the bookkeeping and wording points you raised. It disputes the checks-overshoot and double-refusal claims, because I couldn't reproduce either. bun x tsc --noEmit is clean. test/quotas-running.test.ts and test/quotas.test.ts pass (17 tests). I did not run the forge's full check.
Changed
- Checks limit:
atLimitis now asked per pick inschedule(). A held check is dropped from the candidates and the next one is tried.atLimitalso now sits aboveschedule()'s doc comment, so the comment documentsschedule()again. - Waiting notes:
- waiting in src/checks/service.ts drops an id when the check starts.
- noted in src/crew/dispatch.ts is now per app, held in a WeakMap, and drops an id when the run starts.
- Simultaneous previews: of two deploys in flight together, the older counts against the younger and never the reverse. A wake has no run, so every preview coming up counts against it.
- Sleeping preview at the limit: it stays asleep, and the visitor page (
src/apps/proxy.ts) now says "This preview stays asleep: its owner is at 1 of 1 live previews…", instead of "Nothing is running here yet." I didn't add a test for this one, since setting up an asleep preview in a test is heavy. - Admin form: it renders all five keys from
QUOTA_KEYS. Its blurb still said "nothing enforces these yet", so I corrected it. - Terminal: there is no terminal quota screen in
src/tui. It neither lists nor sets quotas, so none of the five keys can be set there. Adding that is a separate change, and I left it out.
Disputed
- Checks overshoot: I don't think the burst overshoot happened.
start()adds the check toinFlightsynchronously, and thewhileloop inschedule()rebuilds the filter on every iteration. After the first check starts, the second is already counted and held. I still moved the check to the point of starting, which is cleaner and cheap. - Double refusal of previews: I don't think both previews could be refused. The limit check runs right after
state.deployingis set, with noawaitbetween them. The second deploy cannot see the first as "deploying" before the first has already decided. - Tests: the two new burst tests ("two checks … queued together", "older deploy starts and only the younger is refused") also pass on the previous code. They guard the behaviour but do not prove the bug you described.
I could not reach the preview from my sandbox, so I judged the source only. The logic is sound: a limit of one per account, admins exempt, the older deploy counted against the younger, and the queue keeps held runs and checks. Two things block it.
- No test for the sleeping-preview wake refusal. The README promises that a sleeping preview stays asleep when woken at the limit. The runtime sets
wakeRefused, and the visitor page insrc/apps/proxy.tschanges to show it. Nothing intest/quotas-running.test.tsfails if that branch is deleted. The builder said an asleep preview is heavy to set up, but the rule is that every added behaviour has a test. Fix this first: either call the wake path in a test, or pull the wake guard and the proxy wording out of this change. The proxy change has no test either, so check it with the wake guard.
- Quotas can't be set from the terminal.
src/tty.tsandsrc/tuihave no way to list or set the five quota keys, so the page, API and MCP can do something the terminal can't. The same gap existed for the first two keys, so it is not new. Still, the rule is both doors. Open a follow-up issue and link it, or add the screen.
Next, once those are fixed: the two burst tests also pass on the old code, so tighten them or say plainly that they only guard behaviour. Also add a one-line note to the README on how an admin sets the three new keys.
The sleeping-preview wake refusal now has a test, and the terminal gap has a follow-up issue. bun x tsc --noEmit is clean and all 7 tests in test/quotas-running.test.ts pass. I did not run the forge's full check.
- Wake test:
- Two previews of a fresh account come up and rest. The previews limit is then set to 1, and the first is woken directly.
- A request for the second then gets a 5xx page that says "stays asleep" and names "1 of 1 live previews". The preview's status also stays asleep, with no port and the same text as its error.
- I checked that the test fails when the wakeRefused assignment in src/apps/runtime.ts is removed.
- It relies on the first woken preview not resting again before the request reaches the second. The window is 2.5 s, so that's very unlikely, but it is a timing assumption.
- Terminal quotas: I opened #2191 to list and set all five quota keys from the terminal and did not add the screen. The README names that gap and points to #2191. The gap predates this pull for
repositoriesandapps. - Burst tests: I did not tighten the two burst tests. Each now has a comment saying it only guards behaviour and passes without the per-pick check or the deploy ordering. Deploys are decided one after the other and
schedule()re-reads what is in flight after every start, so no test can fail on the old code. - README: it now says how an admin sets the three new keys, on
/admin/limitsor withPATCH /api/v1/admin/settings.
I pushed a type error in the new test first (string where EnvName was expected). It is fixed in the last commit.
The check you reported as failed was the one for ce5830c9, and it now passes on the current head, 9594a5bb. I made no new change and pushed nothing.
ce5830c9 was the push where I left a type error in test/quotas-running.test.ts, passing a string where EnvName was expected. The next commit fixed it. The log you pasted shows only the shard start lines and no failure text, so I'm inferring that was the cause.
The check for 9594a5bb finished ok: "check passed in 180s", with tsc and all four test shards included. test/quotas-running.test.ts was in shard 4.
Approved. The limits are enforced where each thing starts, the refusal names the count ("1 of 1 live previews", "1 of 1 concurrent checks"), and a check or run past its limit stays queued and is not dropped. A forge admin is exempt, identity comes from the owner row in the database, and no user text reaches a shell or SQL. Tests cover previews, wake, checks and runs, and the check passed on this head. I could not reach the preview from my sandbox (the connection was denied), so I judged the code and the tests only, not the pictures or the live page.
Two things I would do next:
- Add the five quota keys to the terminal (#2191, already filed). Until then an admin can set them only on the page, the API and MCP, so the two-doors rule is open for the running limits as it already was for repositories and apps.
- Make the two burst tests fail without the code they guard. They pass without the per-pick check and the deploy ordering, so they prove nothing about those. The wake test also leans on a 2.5 s sleep window; wait on a named state instead.
I merged main into the branch again, resolved the four conflicts, and pushed (8af9f98a). The forge's check hasn't run on it yet.
The earlier merge, 130b48d6, was never pushed. When I ran the full suite on it, 10 tests failed and 1 errored, and I killed the rerun before it finished, so I don't know which ones. After this second merge, bun x tsc --noEmit is clean. I ran only test/quotas.test.ts and test/admin*, and those pass (12 tests). I did not run the full suite after this merge. The 10 failures could be tests that already fail on main, but I haven't checked.
What the conflicts were: main added a storage quota (megabytes of repositories plus app data). This branch adds the previews, runs and checks running limits. Both sides edited the same lines in four files:
src/settings/quotas.ts:QUOTA_KEYSandgetQuotasnow carry all six keys, withstoragebefore the three running limits, and the header comment describes both.src/web/pages/admin-limits.ts: the Account quotas card text names what an account may own, have running and store.test/quotas.test.ts:NONEnow includesstorage: null, so the existing assertions cover all six keys.README.md: the quotas paragraph keepsmain's storage text and the three running-limit sentences, and ends withmain's sentence on setting quotas in the terminal. I dropped my "terminal cannot yet (#2191)" note becausemainnow covers that.
Approved. The three running limits (previews, runs, checks) are asked before anything starts, never of an admin, and refuse with the count. The page, PATCH settings, MCP and the terminal quota screen all read QUOTA_KEYS, so every door gets the new keys. Tests cover each limit, plus the wake refusal and the freed-slot case. I could not reach the preview from my sandbox (network denied), so I judged the served behaviour from the code and tests only. Two things to do next: (1) the two burst tests are labelled as passing without the code they guard; either make them fail on the old behaviour or drop them, since a test that cannot fail is noise. (2) The wake-refusal test depends on a 2.5 s rest window; make it deterministic by pausing the sweep, and add a test that the admin form accepts previews/runs/checks values.
Three new per-account quotas —
previews,runsandchecks— now bound what an account can have running at once, and each is checked before the thing starts.bun x tsc --noEmitis clean andtest/quotas-running.test.tsandtest/quotas.test.tspass (15 tests). I did not runforge.json's fullcheckhere; the forge runs it on this push.What changed
QUOTA_KEYSinsrc/settings/quotas.tsgains the three keys. They are unset by default and the owner changes them on/admin/limits, the API or MCP like the others. Forge admins are never limited.src/apps/runtime.ts):- A preview past the limit does not start. Its deploy run, which is the check the pull shows, fails with "not started: 1 of 1 live previews…". The pull never waits. - A sleeping preview that would be woken past the limit stays asleep. - A redeploy of a preview that is already live is not counted again.
src/crew/dispatch.ts): a run past the limit stays queued, with "queued, not started: 1 of 1 concurrent runs" written once to its log. It starts when one of the account's own runs ends.src/checks/service.ts): same as runs, with "concurrent checks".test/quotas-running.test.tshas a fresh account hit each limit while the admin starts the same things freely. Waits are on named states or flag files. All three behaviour tests fail with the gating removed.test/quotas.test.ts: I changed its exact-equality assertions ongetQuotas, because they listed only the two old keys.scripts/gate.tsneeded no edit, since it already gates everytest/*.test.tsthat isn't excluded.Left out
Failing test I didn't fix:
test/capacity.test.tshas one failure, "an admin changes each one…", oncapacityEnvreturningFORGE_CHECK_SHARDS=4. It fails the same way with my change stashed, so it isn't caused by this work. I suspect the sandbox environment.Try it
previews,runsorcheckslimit of 1 on/admin/limits.Closes #918