feat(sandbox): tenant warm pools — pods already running the dev server - #5830
Merged
Conversation
added 9 commits
August 6, 2026 18:56
A member of an opted-in org opens a project and the dev server is already up: no clone, no install, no boot. A pool is keyed (org, repo) and its pods are bootstrapped by Studio while unbound; a claim from that org binds one. - provider: SANDBOX_TENANT_POOLS-style config (STUDIO_SANDBOX_TENANT_POOLS), pool resolution from the *authenticated user's* org (never a request field), and a reconciler that clones/installs/starts each unbound pool pod and refreshes it — which is also its clone-credential refresh. - daemon: 'claimed' now flips only for a config carrying a user identity, so the housekeeper's idle sweep leaves warm pods alone; env-change restarts dev. - chart: tenantPools renders a SandboxWarmPool per entry (shared template). - optional GitHub push webhook makes a refresh immediate; without it pools still refresh on their own schedule.
…und re-check Two blockers against the spec: - The reconciler reads each SandboxWarmPool's status.selector, but the runner Role never granted sandboxwarmpools. A 403 isn't swallowed (only 404 is), so every tick threw and no pod was ever warmed — while the pool still cost N pods. Grant `sandboxwarmpools: get`. - The pool listing is a snapshot and warming walks it pod by pod over minutes. A claim binding a pod mid-walk got `setup/clone` — a hard reset over a live working tree. Re-read the pod's labels before the config post and before the clone step, per the spec's own mitigation. Errs closed: an unreadable pod counts as bound.
…ap recover, emit depth
- The chart suffixes every object with envName because releases share
agent-sandbox-system; tenantPools wrote a raw literal, so staging and prod
would fight over one SandboxWarmPool. Validate the -<envName> suffix rather
than derive it — the string stays identical on both sides, and a collision
fails at template time. Also require size >= 1.
- The 3-failure cap is per-replica in-memory, so a transient port-forward or
mint failure permanently blackholed a slot until the pod restarted. Back off
30 min, then try once more.
- Add studio.sandbox.pool.pods{pool,org,state} (ready/bound/pending/failed).
Sustained ready=0 is the only signal that separates a working pool from N
pods of pure cost; both silent failure modes produce it.
The housekeeper test found a real bug: `\(true\|false\)` is a GNU BRE extension, so on any sed without it the `claimed` capture yields nothing — which reads as claimed and reaps the warm pod 15 minutes after Studio warms it. That branch had no test at all; the script now has a source-only seam and the sweep's classification is exercised against a real HTTP daemon. Also: the claim's warmpool field (resolved pool / default / none) and the push matcher are extracted as pure functions and tested — the first is the single line that binds a tenant pod and had no coverage. Daemon e2e gains the two cases the spec lists and the first pass skipped: a same-repo branch change after bootstrap keeps its deps, and a different-repo config is refused. Not covered: refresh coalescing (dirtyPools is Set semantics behind a live provider — a harness for it would test the Set, not the behaviour).
Env var is STUDIO_SANDBOX_TENANT_POOLS, not SANDBOX_TENANT_POOLS. Record the webhook's actual route, that `size` lives chart-side and the pool name must carry the -<envName> suffix, the one RBAC verb this needs, the failure cap's backoff, and the depth gauge. Drops the test bullet for a sandboxTemplateRef swap v1 doesn't do, and says plainly what isn't covered.
decocms Bot
pushed a commit
that referenced
this pull request
Aug 6, 2026
PR: #5830 feat(sandbox): tenant warm pools — pods already running the dev server Bump type: minor - decocms (apps/api/package.json): 4.190.4 -> 4.191.0 - @decocms/native (apps/native/package.json): 4.190.4 -> 4.191.0 - @decocms/sandbox (packages/sandbox/package.json): 1.42.0 -> 1.43.0 - deploy/helm/sandbox-env (chart 0.12.1) (deploy/helm/sandbox-env/values.yaml deploy/helm/sandbox-env/Chart.yaml): image.tag/appVersion -> 1.43.0 Deploy-Scope: server
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A member of an opted-in org opens a project and the dev server is already
up — no clone, no install, no Vite boot. Spec (updated to match what shipped):
packages/sandbox/tenant-warm-pools-spec.md.A pool is keyed
(org, repo). Studio bootstraps its pods while they areunbound; a claim from that org binds one instead of a generic empty warm pod.
Off everywhere by default: no pool configured → not a single code path changes.
What's in it
Provider (
packages/sandbox/server/provider/agent-sandbox/)tenant-pools.ts— pool config (STUDIO_SANDBOX_TENANT_POOLS, a JSON array)and resolution. The pool is resolved from the authenticated user's org,
never from a request field: the operator has no notion of a tenant and binds
whatever pool a claim names, so the server-built claim is the boundary.
runner.ts— the claim swapswarmpoolto the pool's name; a reconciler(60s, no-op with no pools) clones/installs/starts each unbound pool pod and
refreshes it every 30 min. That refresh is the clone-credential refresh,
so no pod outlives its ~1h token. Bound pods are never touched.
config post itself and its setup queue collapses concurrent requests — so
duplicated effort, never duplicated clones.
Daemon (
daemon-go)claimednow flips only for a config carrying a user identity. Withoutthis, bootstrapping a pool pod makes the housekeeper reap it 15 min later —
warming a pool would be what kills it. Unbound pods report
claimed:false, prewarmed:true.env-changenow maps to a dev-server restart. It was a silent no-op: themerged env only reached the dev server if some other transition happened to
restart it.
Chart —
tenantPools:renders aSandboxWarmPoolper entry against theshared
SandboxTemplate.Webhook (optional) —
POST /api/_github/webhookmakes a refresh immediateon a push. 503 without
GITHUB_WEBHOOK_SECRET; nothing here needs it —pools refresh on their own schedule regardless.
Testing
Unit: pool parsing/resolution (including "a user of another org never resolves
this pool"), webhook HMAC. Daemon e2e (real binary, black-box):
identity-less bootstrap stays unclaimed, an identity-bearing config claims,
env-only change restarts dev. Full
daemon-e2egreen (209/209).End-to-end on a local rancher-desktop cluster (real operator, real pods, real
daemon built from this branch):
claimed:false, prewarmed:true(housekeeper-safe);warmpool: tenant-local-helloand bindsthat pod; claiming flips
claimed:true;previous user's credential, identity and working tree) and the pool refills;
the refill self-warms;
and a push on another branch/repo matches nothing.
Notes
warmpool. A per-pool templateexists solely to carry per-pool node placement (on-demand / do-not-disrupt),
which this drops — reinstate both together.
connectionIdis optional; without it pods clone anonymously (public repos).dependency.
Summary by cubic
Adds tenant warm pools so members of opted-in orgs open a project with the dev server already running. Pools are per org/repo, bootstrapped by Studio, scoped per release, and off by default.
New Features
warmpoolto the pool name; no pools configured → no behavior change. Addedstudio.sandbox.pool.podsgauge (ready/bound/pending/failed).STUDIO_SANDBOX_TENANT_POOLS(strict parser); when set, the sandbox provider starts eagerly at boot so pods pre-warm before the first request.POST /api/_github/webhook(HMAC viaGITHUB_WEBHOOK_SECRET) marks pools dirty to refresh immediately; periodic refresh still runs without it.tenantPools:renders aSandboxWarmPoolper entry against the sharedSandboxTemplate(chart v0.12.0). Names must end with-<envName>andsizemust be ≥1. Server reads pools fromSTUDIO_SANDBOX_TENANT_POOLS.Bug Fixes
sandboxwarmpools: get; the reconciler re-checks a pod is unbound immediately before config and clone, treating unreadable pods as bound to avoid hard resets on live working trees.claimed:trueonly when a user identity is present; warm pool pods reportclaimed:false, prewarmed:true.env-changenow restarts the dev server.claimedparsing across sed variants and adds tests for probe classification; added tests for tenant-pool binding, push matching, and daemon e2e (branch change keeps deps; different-repo config is refused).Written for commit 785580f. Summary will update on new commits.