ADR-0060 Architecture decision record
A Workspace states its Size, and jr2 reserves all of it
No jr2 pod asked Kubernetes for anything. Sandbox.spec.resources existed and nothing filled it; the Custodian, the Instance Harness and the Orchestrator set nothing either. Every pod was BestEffort, so the scheduler packed Sandboxes without limit, the cluster autoscaler saw no demand, and a full cluster showed up as OOMKill and eviction instead of Pending — an eviction takes /work with it (ADR-0021). The scaling review (2026-09-27) and a lab of real workloads on the home cluster (2026-09-28: Playwright, tsc + vitest on hono, cargo build of ripgrep; one run each) measured what a Sandbox uses. The owner plans to move Workspaces onto Agent Substrate later, so the shape is chosen to move there unchanged.
Decision
- A Workspace states one Size: a ceiling for its whole Sandbox, cpu and memory, nothing else. The shape is Agent Substrate's
ActorTemplate.resources: a KubernetesResourceRequirementsof which onlylimits.cpuandlimits.memoryare accepted;requestsand any other key are refused where they are read.workspace(body, { resources: { limits: { memory: "3Gi", cpu: "2" } } }). - The Size is a fact of the Machine, like its Sandbox Image (ADR-0037, ADR-0049): the author knows the toolchain, the runner, the worker count. It is never per run. The resolution chain, most specific first:
customize(machine, { resources })(the composer, whose Repo may be bigger than the author's) → theworkspace()→sandbox.resourcesinjr2.config.ts(the Instance's default, applied only to a Workspace that states none — never an override of a stated Size, so each fact has one override path) → the kit default. - jr2 reserves the whole Size. Memory request = limit and cpu request = limit: the Sandbox pod is Guaranteed. Memory pressure never picks a Workspace, and placement accounts the way Substrate's does. Density is paid for here and recovered later by suspend (the scaling review's R15), not by overcommit.
- The CPU limit is enforced. Nothing reads a CPU request (the cgroup shows
cpu.max = max), while Node'savailableParallelism, Go 1.25, Rust and cargo, the JVM, .NET and every Substrate runtime read the limit. Throttling is the accepted cost. - Tools that read neither the limit nor affinity get hints, from the Downward API (
limits.cpu, divisor 1):JR2_CPUS,OMP_NUM_THREADS(which also makesnprocanswer N),PYTHON_CPU_COUNT,GOMAXPROCS.os.cpus()still reports the node, and Playwright sizes its workers from it alone, so a Machine passes--workers=$JR2_CPUS; the Agent is told its CPU count by the Briefing (ADR-0062), never by the Frame. - jr2 splits the Size inside the pod. The Custodian takes a fixed kit slice (64Mi, 50m). The User Container takes its split if the Machine states one —
userwidens from a string to{ image, resources?: { limits: { memory?, cpu? } } }(ADR-0005 foresaw the widening) — and otherwise shares the pod budget with no limit of its own. The Sandbox Effector, in a pod that has one (ADR-0068), has no split and no limit of its own and shares the pod budget the same way. The Harness container gets the rest as its own limit, so an OOM stays per container where it can. Pod-level resources (KEP-2837) hold the whole-pod ceiling. A server with the PodLevelResources gate off drops them without a word, sojr2 updry-runs a pod that states them and refuses the cluster when they are gone.jr2 uprefuses a split that leaves the Harness below its floor (256Mi) and names the line. - Kit numbers. Default Size: memory 2Gi, cpu 1. Node gives each process a V8 heap of about 55% of its container limit, and hono's
tscaborted with a heap error below about 1.1Gi, so 1Gi fails a common task. 2Gi coverstsc+ vitest,cargo buildand Playwright at 2 workers; Playwright at 4 workers and large monorepos state a Size. NoNODE_OPTIONSheap raise: a V8 abort is a tool error the Agent reads, while a bigger heap turns it into a kernel kill. /dev/shmis a memory-backed emptyDir in the Harness container only,sizeLimit= 25% of the Harness share (minimum 64Mi). It reserves nothing extra (shm is charged inside the Harness limit), and a full shm is ENOSPC or SIGBUS — a tool error, never an OOM (lab). The Harness clears it at start: shm files outlive a container restart and stay charged to the pod, which crash-looped the restarted container in the lab. There is noshmfield; a Machine that needs more raises its Size./workdisk is a kit-fixed ephemeral-storage request of 10Gi on the Harness container, with no cap. The request places the pod and ranks it last under node disk pressure; nosizeLimitand no limit, because passing one evicts the pod and loses/work(T12). Disk is not part of the Size: Substrate's template size is cpu and memory only.- Priority.
jr2 upcreates two cluster-scoped PriorityClasses beside the CRDs:jr2-control(100000, PreemptLowerPriority) for the Orchestrator, the operator and the Repo cache agent — small, fixed and needed by every Sandbox — and the Instance Effector (ADR-0068), small and fixed;jr2-sandbox(1000, preemptionPolicyNever) for Sandboxes and the Instance Harness — a pod of ordinary priority cannot preempt a live Workspace, and a waiting Sandbox evicts nobody. Both stay far below the system classes. A cluster with its own scheme names its classes inpriorityClasses: { control?, sandbox? }. - Voluntary disruption. A Sandbox pod and the Instance Harness pod carry
cluster-autoscaler.kubernetes.io/safe-to-evict: "false"andkarpenter.sh/do-not-disrupt: "true": moving either loses work (/work; live conversations). The Orchestrator, the operator, the Repo cache agent and the Instance Effector carry neither — a restarted Orchestrator re-attaches (ADR-0007), and a stopping Effector finishes its Effects first. No PodDisruptionBudget:maxUnavailable: 0blocks node upgrades without end. A drain ends inworkspace.lost, and the body's policy decides (ADR-0021). - jr2's own pods are kit-sized, with no config key: memory request = limit, a CPU request, no CPU limit (a provisioning burst needs CPU, and no Substrate move applies to them). Orchestrator 500m / 1Gi. Instance Harness 250m / 1Gi (it frees every conversation no live run holds — ADR-0031). Operator 50m / 256Mi (it caches every Pod in the cluster). Instance Effector 50m / 128Mi. Repo cache agent 50m, memory request 128Mi and limit 1Gi — the one exception, since
git index-packspikes and its OOM costs only a refetch.
Considered options
- Sizes as named classes in
jr2.config.ts, which the Machine names (workspace(body, { class: "browser" }), the config maps the class to resources). Rejected: it breaks packaging. Two packages each namebrowserand mean different sizes, and the consumer must supply numbers only the package's author knew. ADR-0052's named class stays placement only — node labels are the cluster's facts; a memory need is the Machine's. - Raw
ResourceRequirements, all of it. Rejected: the author could set a request below the limit, leave out the memory limit, or reach fields Substrate refuses, and a Machine would depend on something the move drops. - A jr2 shape (
{ memory, memoryLimit?, cpu }). Rejected for the same reason in the other direction: one more translation at the move, for no gain the Substrate subset does not give. - Burstable: reserve the working peak, allow bursts to the limit. About twice the density. Rejected: with no suspend, several Workspaces bursting at once is node pressure, and pressure evicts a whole pod with its
/work. - Reserve CPU, never throttle. Chosen first, then reversed: no mechanism tells a tool its CPU request (the lab tried affinity, CPU Manager, LXCFS, gVisor, Kata and
LD_PRELOADshims; all but affinity need a limit, and affinity missesos.cpus()and lets pods pin the same cores), while every container-aware runtime reads the limit. - An
shmfield, or a/workdisk field, on the Size. Deferred: Substrate has neither. A jr2 field beside the Size can come when a real Machine needs one. /dev/shmwithout asizeLimit, or the container's 64Mi default. Rejected: unbounded, a full shm OOM-kills the container and poisons its restart; 64Mi crashed Chromium renderers when Playwright's--disable-dev-shm-usagewas off.
Consequences
- ADR-0005 is rewritten in place: containers now carry limits from the Size, the User Container may take a split, and
/dev/shmjoins the Harness container's mounts. - ADR-0052 gains
sandbox.resourcesbesidenodeSelectorandtolerations; its reserved named class stays placement only, so itsnode:name stays. - An OOM in the Harness container still kills the Harness with the Agent's processes (cgroup v2 group kill). Containing it is ADR-0061.
- A Workspace parked on a Gate holds its whole Size. That is the density problem R15 exists for.
- On a move to Substrate, a Workspace with a User Container split needs a microVM pool: Substrate accepts per-container limits on microVM runtimes only, and gVisor refuses them.
jr2 upwarns — never refuses — when no current Sandbox node could hold a Workspace's Size (the ADR-0052 stance: the node set moves).- GLOSSARY.md gains Size.
- The kit-sized control pods ask a node for about 900m a scenario before any Sandbox, so the
@kindtier on a 4-vCPU hosted runner runs two workers, not the profile's four (just e2e-kind 2in the release job). v0.4.0's release run measured the alternative: four workers left every Sandbox Pending onInsufficient cpu. The requests are the kit's and are not lowered for a runner.