Public: anyone can read this; the crew can change it.

An agent deploys a forge for its person, and that forge scales to zero when idle #9547

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

Hosted forges now sleep when idle and wake on the next page load, git push or API call. Several items in the issue are not finished; they're listed below. `bun scripts/gate.ts`, `bun x tsc --noEmit` and `bun scripts/design-lint.ts` pass. The new test and 132 related tests pass, `test/mcp-size.test.ts` among them. The full suite is left to the forge's check. The cell path (a forge in its own proces…

shipped production is serving it deploy a4add02

opened this

Hosted forges now sleep when idle and wake on the next page load, git push or API call. Several items in the issue are not finished; they're listed below. bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts pass. The new test and 132 related tests pass, test/mcp-size.test.ts among them. The full suite is left to the forge's check. The cell path (a forge in its own process) could not run here, because cells are skipped in this sandbox.

What changed

  • Sleep: a forge with no request for FORGE_CHILD_IDLE_S (600 s by default) sleeps. A hosted child's app closes and its apps stop. A cell child's process stops. Its data stays on disk under children/<bodyId>. A sweeper checks every few seconds (wakeGate and sleepIdleChildren in src/spawn/host.ts).
  • Wake: a request to a sleeping forge wakes it. A cell forge that isn't up within 4 s gets a plain "Waking up" page (503, retry-after, reloads itself). A git push or API call waits for the wake and then goes through.
  • Limits, each refused in plain words (src/spawn/sleep.ts):

- Forges per person: 3 (FORGE_FORGES_PER_PERSON). Site admins are exempt. - Awake at once: 8 (FORGE_CHILD_AWAKE_MAX). A sleeping forge doesn't count. - Wakes per forge: 20 an hour (FORGE_CHILD_WAKES_PER_HOUR). - Disk: the existing child quota and each cell's disk cap, not a new per-person storage limit. The ledger now records the owner so forges can be counted.

  • Owner view: /forges shows each forge as awake or asleep, with its last activity. It has new Stop and Remove buttons (POST /forges/:name/stop and /remove, admin only, same-origin). Remove deletes the ledger and the data, and writes a forge.removed event (src/spawn/manage.ts).
  • /agents text: replaced "Hosted forges are not available yet" with how to get a hosted forge, that it is free within limits, and that the person brings their own model credential or the crew stays off.
  • Test: test/forge-sleep.test.ts sleeps a forge, wakes it on a page load, then sleeps it again and wakes it on a git push, and checks the earlier push is still there. It also covers the limits, the waking page, and stop and remove. How-to is in docs/features/forge-sleep.md.

What I left out

  • A one-step create form on /agents itself. It points to the existing /forges/new page and the MCP spawn tool.
  • Per-person storage accounting.
  • Letting guests spawn.
  • A payment path. The page just says it is free within limits.
  • A new MCP tool or any raised MCP ceiling.
  • Model credential handling beyond what exists. Children already start from childEnv and so never inherit this forge's credential.
  • Boot wake: on boot the parent still opens every hosted child, as before. Each one sleeps again after the quiet period, but they aren't asleep from the start.

Try it bun test test/forge-sleep.test.ts, or as an admin open /forges and press Stop on a forge. Visiting its address wakes it.

Closes #9444

commented

Hosted forges now sleep when idle and wake on the next page load, git push or API call. Several items in the issue are not finished; they're listed below. bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts pass. The new test and 132 related tests pass, test/mcp-size.test.ts among them. The full suite is left to the forge's check. The cell path (a forge in its own process) could not run here, because cells are skipped in this sandbox.

What changed

  • Sleep: a forge with no request for FORGE_CHILD_IDLE_S (600 s by default) sleeps. A hosted child's app closes and its apps stop. A cell child's process stops. Its data stays on disk under children/<bodyId>. A sweeper checks every few seconds (wakeGate and sleepIdleChildren in src/spawn/host.ts).
  • Wake: a request to a sleeping forge wakes it. A cell forge that isn't up within 4 s gets a plain "Waking up" page (503, retry-after, reloads itself). A git push or API call waits for the wake and then goes through.
  • Limits, each refused in plain words (src/spawn/sleep.ts):

- Forges per person: 3 (FORGE_FORGES_PER_PERSON). Site admins are exempt. - Awake at once: 8 (FORGE_CHILD_AWAKE_MAX). A sleeping forge doesn't count. - Wakes per forge: 20 an hour (FORGE_CHILD_WAKES_PER_HOUR). - Disk: the existing child quota and each cell's disk cap, not a new per-person storage limit. The ledger now records the owner so forges can be counted.

  • Owner view: /forges shows each forge as awake or asleep, with its last activity. It has new Stop and Remove buttons (POST /forges/:name/stop and /remove, admin only, same-origin). Remove deletes the ledger and the data, and writes a forge.removed event (src/spawn/manage.ts).
  • /agents text: replaced "Hosted forges are not available yet" with how to get a hosted forge, that it is free within limits, and that the person brings their own model credential or the crew stays off.
  • Test: test/forge-sleep.test.ts sleeps a forge, wakes it on a page load, then sleeps it again and wakes it on a git push, and checks the earlier push is still there. It also covers the limits, the waking page, and stop and remove. How-to is in docs/features/forge-sleep.md.

What I left out

  • A one-step create form on /agents itself. It points to the existing /forges/new page and the MCP spawn tool.
  • Per-person storage accounting.
  • Letting guests spawn.
  • A payment path. The page just says it is free within limits.
  • A new MCP tool or any raised MCP ceiling.
  • Model credential handling beyond what exists. Children already start from childEnv and so never inherit this forge's credential.
  • Boot wake: on boot the parent still opens every hosted child, as before. Each one sleeps again after the quiet period, but they aren't asleep from the start.

Try it bun test test/forge-sleep.test.ts, or as an admin open /forges and press Stop on a forge. Visiting its address wakes it.

commented

I pushed a new commit whose message has the Try: line the gate wanted. The gate ran clean here, but I haven't seen the forge's check re-run on the push yet.

The check failed only on the rule that asks for a Try: line in a commit message. It wasn't a code or test failure. The commit has no code changes.

Try: open /forges as an admin on the preview, press Stop on a forge, then visit its <name>--host address to wake it.

commented

I changed how a sleeping forge in its own process is woken and pushed it. The cell test that failed can't run in this sandbox, so I haven't seen it pass. The forge's check will be the first run of it.

Cause: that test requires the first request to a cell forge to get a 503 straight away, in under a second. My wake logic made every request wait up to 4 seconds for the forge to start.

Fix: page requests are answered with the "waking up" page immediately, and the forge starts behind it. Only git (.git/) and /api/ calls wait up to 4 seconds, so a push still wakes the forge and goes through. That is the same behaviour the cell test expects.

Checks run here:

  • bun x tsc --noEmit passes.
  • test/forge-sleep.test.ts and test/spawn-cell.test.ts: 25 passed, 3 skipped, none failed. The skipped ones are the cell tests that can't run here.

Try: open a sleeping cell forge's address on the preview and you get the waking page. A git push to it then waits for the forge to wake and goes through.

Changes requested — before the latest push

Sleep and wake work for a hosted child, and the test covers sleep, page wake, push wake and data kept. Changes needed, in one round:

  1. No terminal door (fix first). There is no src/tui/ screen or entry for awake/asleep, Stop or Remove. Both doors are required, and a write made there must reach web viewers live. The hosted-forge create path (/forges/new, MCP spawn) also needs a terminal door, or a link to the existing one.
  2. The waking page is hand-rolled in src/spawn/sleep.ts wakingResponse: inline <style> attributes, raw HTML and no tokens. That departs from docs/DESIGN.md, and the CSP may block the inline style. Use the design system's page shell and tokens.
  3. forges.ts markup. The data-state span should be badge(). Check that class="inline" is a named class. The "Forges you sent" button text on the result page is wrong for this action.
  4. Remove is one click and irreversible, with no confirmation. With JavaScript off it needs a confirm page (a GET that shows what will be deleted, then a POST).
  5. Anyone can hold a forge awake or lock it out. wakeGate runs for unauthenticated requests. Anonymous hits to sleeping forges' hosts fill the 8-awake ceiling, burn the 20/hour wake budget so the owner cannot wake their own forge, and keep it awake forever by touching touched. Rate-limit by socket address before waking, and don't let unauthenticated requests refresh idleness. Also, awakeCount only grows after the open finishes, so concurrent wakes can pass the ceiling together. Reserve the slot in wakeGate.
  6. Sleeping kills background work. Idle is measured by requests only. A forge running crew work or a long push can be closed under it. Skip forges with in-flight work or open sockets.
  7. Issue boxes not built. There is no one-step create from /agents (only a pointer in llms.txt). Size is not shown on /forges. The "free within limits" and "crew is off until you bring a credential" wording is not on any page, only in docs and llms.txt.
  8. Tests. Nothing tests wakeGate refusing a sleeping forge at the awake ceiling or the wake rate. Nothing tests the per-person limit being enforced through spawnForge, or the Stop/Remove POST routes (admin only, non-admin gets 404, cross-origin refused). Nothing tests the cell wake path, which is skipped here. The header comment of test/forge-sleep.test.ts is copied from another test (hook relay) and wrong.
  9. Clean. closeOne has a stray bare { } block. The held doc comment in cell.ts was deleted and replaced with the seen comment. The import order in server.ts puts wakeGate mid-list.
commented

All nine review points are addressed and pushed to #9547. Two gaps remain: the cell wake path has no test, and the websocket and streaming-body cases from point 6 are not covered. bun x tsc --noEmit, the gate, bun scripts/design-lint.ts and bun test test/mcp-size.test.ts pass. The new test/forge-sleep.test.ts and about 200 related tests pass. I did not run the whole suite; the forge's check does that. One of the two failures along the way was an existing TUI test I updated for the new "awake/asleep" prefix.

  1. Terminal door. The "Forges you sent" screen now shows awake/asleep and size. s stops a forge and x removes one after a confirm dialog. Both go through src/spawn/manage.ts, which writes forge.stopped and forge.removed events, so web viewers see them. Creating a forge from the terminal is the existing New forge screen. I did not add anything new there.
  2. Waking page. It is now built in src/web/pages/waking.ts with page(), empty() and btn(): no inline styles, and it follows the theme. A browser reloads itself through a refresh header; git and API clients get a plain sentence.
  3. forges.ts markup. The awake/asleep state is a badge(), size is shown, and form.inline is an existing named class. The result page button now says "Back to your forges".
  4. Remove. Remove is now a link to GET /forges/:name/remove, which lists what will be deleted, then a POST. It works without JavaScript.
  5. Stranger abuse.

- Address limit: waking a sleeping forge is rate-limited per address and for the whole forge with takeDoor("api"), the existing limiter. - Idleness: a stranger's request never refreshes idleness. A request carrying a session cookie or an authorization header does. - Wake budget: the 20 wakes an hour applies only to strangers, so an owner cannot be locked out of their own forge. - Reserved slot: wakeGate reserves the awake slot before the forge opens, so two concurrent wakes cannot both pass the ceiling. - Limitation: I only check that the credential is present, not that it is valid. A forged header still passes that check, so it is limited only by the per-address rate.

  1. In-flight work. A forge is not put to sleep with a request in flight (holdChild for hosted, hold for cells) or with a queued or running run in its database. Not covered: an open websocket, and a cell response body still streaming after its headers.
  2. Issue boxes.

- Create: /agents now has a name form that goes to /forges/new with the name filled in. It is not a create-in-one-submit; the /forges/new page still needs its own confirm. - Wording: the "free within limits" and "crew off until you bring your own model credential" text is now on both /agents and /forges/new. - Size: shown on /forges.

  1. Tests. The new tests cover:

- wakeGate refusing at the awake ceiling, with the reserved slot - the stranger wake budget, with a session exempt - a stranger not holding a forge awake, and a signed-in request doing so - the in-flight guard - the per-person limit through spawnForge - Stop and Remove: non-admin gets 404, cross-origin gets 403, and the confirm page - the corrected header comment

Nothing tests the cell wake path. Cells are skipped in this sandbox, so only the forge's check can exercise it. In particular, the hold call around the cell forward in answerFromCell is untested.

  1. Cleanup. I removed the stray block in closeOne, restored the held comment in cell.ts, and put the server.ts imports in order.

Try: as an admin on the preview, open /forges and press Stop on a forge, then open /forges/<name>/remove to see the confirm page. In the terminal, open "Forges you sent" and press s or x.

commented

I added the missing Why now: line in a new commit and pushed it to #9547. bun scripts/gate.ts runs clean here, but I haven't seen the forge's check re-run on the push yet.

The check failed on a commit-message rule. The pull adds src/web/pages/waking.ts, and the rule wants a Why now: or Pipeline: #N line saying why it goes ahead of the pipeline. The new commit has no code changes, so nothing else needed re-testing.

Why now: the owner asked for forges that scale to zero (#9444), and a sleeping forge needs a themed waking page rather than an error to be usable at all.

Try: open a sleeping forge's address in a browser on the preview and you get the waking page.

Changes requested — before the latest push

Most earlier asks are met: terminal door (s/x via spawn/manage.ts with events), themed waking page, confirm-first remove, per-person limit through spawnForge, in-flight guard, and the how-to in docs/features/forge-sleep.md. The clone does not have e7eebea, so I read the whole diff rather than the increment.

Blocking, one thing to fix first: carriesCredentials in src/spawn/host.ts (used by wakeGate) trusts the mere presence of an authorization header or a forge_session= cookie that the client wrote. Any stranger can send authorization: x and (a) skip the 20-wakes-an-hour budget, and (b) call touch() on every request so the forge never goes idle. That defeats scale-to-zero and the wake limit, and it is the exact thing the bar forbids: limits keyed on a header the client wrote. The pull description admits the gap. Fix: verify the credential before it counts (resolve the session cookie, or the token, against the child ledger/DB or the parent auth), or key the idle refresh and the wake budget on the socket address plus a verified identity only. Add a test where a bogus Authorization header and a bogus forge_session cookie get 429 after the budget and do not refresh idleness (the current test uses a real token only).

Box 1 (one-step create from /agents) is not met: /agents only has a name form that GET-forwards to /forges/new, which still needs its own submit. Either make it a real create, or leave the box open and say so.

The cell wake path and the hold() around cell forwards remain untested (sandbox limit; accepted, but note it). Non-blocking: the streaming-body and websocket gaps are documented; fine for now.

commented

Hosted forges sleep after FORGE_CHILD_IDLE_S (600 s) with no request, and wake on the next page load, git push or API call. This push fixes the two points from review: a credential only counts toward the wake limits and idleness once the child forge has verified it, and /agents now makes a forge in one step.

Credentials are checked before they count (the blocking point). Before, carriesCredentials in src/spawn/host.ts only checked whether an authorization header or a forge_session= cookie was there. A stranger could send either one to skip the 20-wakes-an-hour budget and to keep a forge from ever going idle.

  • A new module, src/spawn/vouch.ts, looks the session cookie or token up in the child forge's own database. Tokens are accepted as Bearer, token, or as a forge_ Basic password.
  • The lookup only reads: it uses the open database when the forge is awake in-process, and otherwise opens children/<bodyId>/forge.db read-only. It writes no last-seen time.
  • It also checks that the session or token hasn't expired and that the account is active.
  • wakeGate now passes the body id to carriesCredentials, so the wake budget and the idle refresh depend only on a credential the child accepts.
  • A Basic password that isn't a token is not checked, so that request is treated as a stranger's. Hashing a password on every request is the cost the limits are there to avoid.

One-step create from /agents (box 1). The form there now sends the name straight to POST /forges/new. That runs the full spawn with default choices, including the same-origin check, and returns the claim link.

Tests (in test/forge-sleep.test.ts):

  • A bogus authorization header, a bogus Bearer forge_… token and a bogus forge_session cookie get 429 once the hourly wake budget is spent, and they don't keep an awake forge from sleeping.
  • With timed sweeps, a made-up session cookie lets the forge sleep and a real session cookie of the child keeps it awake.
  • The /agents form posts to /forges/new, and posting just a name creates the forge.
  • Both credential tests fail against the old host.ts.

Not covered: the cell wake path and the hold around cell forwards are still untested, because this sandbox skips cell tests (now noted in docs/features/forge-sleep.md). An open websocket, and a cell's response body that is still streaming after its headers, can still miss the sleep check; the doc names both.

Checks run: scripts/gate.ts, tsc --noEmit and design-lint pass. scripts/fast.ts named 1505 test files for this branch's diff, so I picked the ones that cover the change: forge-sleep, agents (page, list, etag), new-forge, spawn-anon, child-quota, tui-forges, spawn-host, spawn-host-push and hosted-child-crew all pass (90 tests). The sandbox blocked the tests' outbound requests to *--forge.test; no assertion depends on them.

To try it on the preview: send a request with the child forge's Host and authorization: x. While the forge sleeps, that request should get 429 once the hourly wake budget is spent, and while it's awake it should no longer keep it from sleeping. Then submit the name form on /agents: it should come back with a claim link.

🤖 Generated with Claude Code

Approved

Both earlier asks are met. (1) A credential now counts toward the wake budget and idleness only when the child itself vouches for it: src/spawn/vouch.ts looks the session or token up read-only in the child database, checks expiry and an active account, and writes nothing. Tests show bogus authorization and cookie values get 429 and do not hold a forge awake, while a real session does. (2) The /agents form posts the name straight to POST /forges/new, which is same-origin checked and uses the default choices; a test covers it. Non-blocking: vouch opens a sqlite file read-only for each request that presents a credential to a sleeping forge, ahead of takeDoor, so move the rate check before the lookup if that ever shows in load. Non-blocking: the cell wake path and the hold around cell forwards are untested here; the forge check will be their first run. Left open: a per-person storage limit (box 3); only the existing per-child quotas apply.

commented

Left #9444 open: 1 unticked item remain (Limits per person: forges per account, storage, wake rate, and a forge-wide ceiling on how many run at once. Each refuses in plain words. A sleeping forge doesn't count against the running ceiling.).

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs