Resource-aware scheduling — design
Status: SHIPPED (2026-07-08, all three phases — feature, footer budget line,
RunRequest.memorywire). The spec below is the implemented contract.Owner-requested. The model (
exec.resources: { cpus, memory }, default 0, percent-or-number, admission-not-enforcement) is LOCKED. This doc makes it precise and implementation-ready against the current two-tierReadyHeapscheduler.
What we’re solving
Section titled “What we’re solving”Today the scheduler admits a ready task on ONE constraint: the running
count is below concurrency (src/graph/scheduler.ts:389). Every task
costs exactly one slot. That’s wrong for heterogeneous graphs: a 12 GB
linker, a 6-core type-check, and a near-free lint each take one slot,
so a run either over-subscribes RAM/cores (OOM, thrash) or under-uses
them behind a too-conservative --concurrency.
We add per-task resource reservations: a task declares CPU and memory demand, and the scheduler packs ready tasks so concurrent reservations never exceed a budget on either axis — 2-D bin-packing admission layered on the existing count limit. Turbo and Nx have nothing like it (flat task-count concurrency only); Bazel’s local resources are the precedent.
Admission control, not enforcement. vx uses the numbers only to
decide what to co-schedule. It does NOT cgroup-limit, nice, or kill a
task that exceeds its declaration — that stays the job of exec.timeout
and the OS. State this everywhere the feature surfaces.
Hot-path constraints:
- The admission check runs in the scheduler’s inner loop → the resolved
cost must be a plain
Map.get, never a parse. Parse/percent-resolve ONCE up front inrun.ts; budgets computed once per run. - Every existing config declares nothing → cost 0 → must hit a path byte-identical to today (no admission math, no parking, same keys).
The model (locked, restated precisely)
Section titled “The model (locked, restated precisely)”One new optional object on ExecConfig (src/config.ts), beside
timeout and retries — grouped so a future axis (gpu, custom) slots in
without new top-level fields:
exec: { command: 'vitest run integration', resources: { cpus: 4, memory: '2GB' }, // or: { cpus: '50%', memory: '25%' }}| Field | Type | Default | Meaning |
|---|---|---|---|
exec.resources.cpus | number | "<n>%" | 0 | CPU units, or percent of the CPU budget |
exec.resources.memory | number | "<size>" | "<n>%" | 0 | bytes, a size string, or percent of RAM |
cpus: anumberis CPU units (fractional allowed,0.5);"<n>%"is percent of the CPU budget —cpus: "50%"on an 8-unit budget → 4.memory: anumberis bytes; a size string is"2GB"/"512MB"(K/M/G/T, powers of 1024, the existingparseSize);"<n>%"is percent of the memory budget.- Default
0= reserve nothing, run freely. A task that declares0(or omits the field) is NOT gated on that axis — only the existing concurrency-count limit applies. Reservations coordinate among tasks that opt in. Every current config is byte-identical, in scheduling AND in cache keys.
Budgets (what % resolves against; both overridable)
Section titled “Budgets (what % resolves against; both overridable)”- CPU budget = the run’s
concurrency(options.concurrency ?? workspaceConfig?.concurrency ?? core count,src/orchestrator/run.ts).cpus: 1degrades to old count semantics;cpus: 4on a budget of 8 means at most two at once. - Memory budget =
os.totalmem(), overridable with a new--memory <size>CLI flag + workspacememoryfield.- Container caveat (document prominently): in a cgroup-limited
container
os.totalmem()reports the HOST’s RAM, not the limit — a"50%"reservation can badly over-admit. CI/container users should pass--memory(or set workspacememory) to the real limit. - When no task declares memory the axis is a no-op (see the gate).
- Container caveat (document prominently): in a cgroup-limited
container
Admission (2-D)
Section titled “Admission (2-D)”A ready task is admitted only when ALL hold:
running-count < concurrency(unchanged), ANDreservedCpu + cpu ≤ cpuBudget, ANDreservedMem + mem ≤ memBudget,
with three encoded rules:
- Zero never blocks. A
0cost is exempt from that axis entirely (needs no headroom, reserves none) — the “run freely” law. - Highest-priority-first, with backfill. Pop order stays (priority DESC, enqueue-seq ASC). When the head doesn’t fit, smaller ready tasks behind it may be admitted (backfill) while it waits.
- Solo-clamp (no deadlock). A task whose reservation exceeds a whole
budget can never satisfy rule 2/3; it is admitted alone on that axis
(when
reserved === 0). An idle pool always admits at least one ready task, so the run can never deadlock.
Cache key
Section titled “Cache key”cpus/memory are scheduling hints with zero effect on outputs, so
they are stripped from hashTaskConfig before hashing. Tuning a
reservation never busts a cache. No CACHE_VERSION bump — see §4.
Recommendation
Section titled “Recommendation”Ship the locked model as a 2-D admission gate on the existing two-tier
scheduler: resolved once in run.ts into an id → {cpu, mem} map that
is omitted entirely when no task opts in (the single gate that keeps
every current run byte-identical); backfill via park-within-tick (keeps
the heap O(R log R) and the exact priority + FIFO-among-equals
contract); restores reserve 0; the resources object stripped from the
key (a one-key drop — grouping makes the strip trivial). Rejected
alternatives: flat exec.cpus/exec.memory (owner picked the grouped
object — extensible to a third axis without new top-level fields),
enforcement via cgroups/rlimits (a hint, not a cap), and CPU-only slot
weighting (memory is the axis that actually OOMs CI).
Concrete spec
Section titled “Concrete spec”1. Schema (src/config.ts)
Section titled “1. Schema (src/config.ts)”Add to ExecConfig after retries, plus an exported ResourcesConfig:
/** * Resource RESERVATIONS for admission control (NOT enforcement — vx does * not cgroup-limit the task; it only decides what to co-schedule). Each * axis defaults to 0 = reserve nothing: the task runs subject only to the * concurrency-count limit. A pure scheduling hint — the whole object is * stripped from the cache key, so tuning it never invalidates a result. */export interface ResourcesConfig { /** CPU units (fractional ok), or a "<n>%" string of the CPU budget * (the run's concurrency). */ cpus?: number | string /** Bytes, a size string ("2GB", "512MB" — powers of 1024), or a "<n>%" * string of the memory budget (os.totalmem() or --memory). */ memory?: number | string}
// on ExecConfig:resources?: ResourcesConfigType the axes number | string (not a template-literal type) so the
loader owns validation with a clear message instead of a cryptic TS error.
2. The resolver (new src/orchestrator/resources.ts, pure)
Section titled “2. The resolver (new src/orchestrator/resources.ts, pure)”Budget-parameterized and pure — unit-testable without a scheduler.
export interface ResourceCost { cpu: number mem: number} // absolute; cpu may be fractionalexport const ZERO_COST: ResourceCost = { cpu: 0, mem: 0 }
export function resolveCpu(v: number | string | undefined, cpuBudget: number): numberexport function resolveMem(v: number | string | undefined, memBudget: number): number
// Whole graph → id → cost, OMITTING zero-cost tasks: an empty map means// "no reservations declared" — the gate everything else keys off.export function resolveResourceCosts( nodes: Map<string, TaskNode>, cpuBudget: number, memBudget: number,): Map<string, ResourceCost>Rules: undefined → 0; cpus number → itself; "<n>%" →
(pct / 100) * budget, kept fractional (rounding is display-only);
memory number → bytes; size string → parseSize; percent → as cpus.
resolveResourceCosts reads each node’s config.exec?.resources and
inserts an entry only when cpu > 0 || mem > 0.
Boundary fix — move parseSize to util. It lives in
src/cli/cache.ts, and orchestrator cannot import cli
(tests/module-boundaries.test.ts). Move it to src/util/index.ts (a
leaf everyone may import) and re-export from cli/cache.ts so callers
and tests are unchanged. Note parseSize accepts integer sizes only —
"1.5GB" is null and the loader rejects it.
3. Loader validation (src/workspace/project-loader.ts)
Section titled “3. Loader validation (src/workspace/project-loader.ts)”In validateProjectConfig, right after the retries check. Form only —
no budget needed. exec.resources must be a plain object with ONLY the
known keys (cpus, memory) — reject unknown keys, mirroring how the
sandbox block is validated against a field allowlist. Then per axis:
cpus: number → finite and>= 0; string → must match/^\d+(\.\d+)?%$/(reject"%", sizes like"2GB", garbage); anything else → error.memory: number → integer>= 0; string → a valid percent OR aparseSize-accepted size (reject"%","5X","1.5GB"); anything else → error.
Messages mirror the existing style:
${where}.exec.resources.cpus must be a non-negative number or a "<n>%" string,
${where}.exec.resources.memory must be a non-negative integer (bytes), a size string like "512MB", or a "<n>%" string,
${where}.exec.resources has unknown field "gpu".
persistent + reservation is allowed, but the reservation is released
when the task signals READY, NOT held for the task’s whole lifetime.
(executePersistentTask resolves its outcome at ready; the scheduler
releases the reservation when that promise settles.) This is deliberate:
lifetime-holding would deadlock a persistent cpus:'100%' + a downstream
cpus:'100%' forever — a permanently-held axis never goes idle for the
solo-clamp. So a persistent task’s reservation coordinates admission only
until it’s ready; a heavy downstream task can co-schedule with the
still-running server afterward (advisory admission, not enforcement — the
same contract as the rest of the feature). [Corrected 2026-07-09 after the
adversarial review found the code releases at ready.]
4. Cache-key stripping (src/orchestrator/task-hash.ts)
Section titled “4. Cache-key stripping (src/orchestrator/task-hash.ts)”hashTaskConfig hashes JSON.stringify(cfg) behind a WeakMap memo.
Project the config before hashing:
function hashableConfig(cfg: TaskConfig): unknown { // Fast path: nothing to strip → cfg unchanged → byte-identical bytes. if (cfg.exec?.resources === undefined) return cfg const { resources, ...execRest } = cfg.exec return { ...cfg, exec: execRest }}(The grouped object pays off here: the strip is a single-key drop.)
Why no CACHE_VERSION bump: a task declaring no resources takes
the fast path and stringifies exactly as today. A task that declares one
is by definition new (the field didn’t exist), so there’s no prior key
to preserve. Aside: timeout/retries are NOT stripped today (their
keys are “distinct by design” per the decision log); retro-stripping
them would bump CACHE_VERSION — deliberately out of scope.
5. Scheduler admission (src/graph/scheduler.ts)
Section titled “5. Scheduler admission (src/graph/scheduler.ts)”The load-bearing part. Current structure: ReadyHeap max-heaps,
execReady / restoreReady tiers, the active counter, takeReady,
and while (active < concurrency && …).
5a. ScheduleOptions additions
Section titled “5a. ScheduleOptions additions”/** Resolved per-task reservations. Absent id ⇒ zero cost. Undefined/empty * ⇒ no reservations declared: byte-identical legacy path. */resourceCosts?: ReadonlyMap<string, ResourceCost>/** CPU budget. Defaults to `concurrency`. */cpuBudget?: number/** Memory budget. Defaults to Infinity (axis off). */memBudget?: numberResourceCost is declared structurally in graph/scheduler.ts (like
VerifyVerdict — graph can’t import orchestrator) and re-exported via
graph/index.ts; orchestrator/resources.ts imports it from graph
(an allowed edge).
5b. ReadyHeap — two O(1)/O(log R) additions
Section titled “5b. ReadyHeap — two O(1)/O(log R) additions”Backfill must not corrupt FIFO-among-equals; the seq enforces that contract, so a repushed element keeps its ORIGINAL seq:
push(id: string, seq: number = this.next++): void // sift-up unchangedpeekSeq(): number { return this.seq[0] ?? -1 } // capture before pop5c. Backfill via park-within-tick
Section titled “5c. Backfill via park-within-tick”Naive pop-aside-and-repush per takeReady call would rescan the same
too-big head repeatedly and mint fresh seqs. Instead exploit an
invariant:
Within one synchronous
tick(),reservedonly INCREASES (release happens in the async.then/.catch, which calls a freshtick()). A task that failsfitsnow cannot become admissible later in the SAME tick.
So park a non-fitting executor for the rest of the tick and repush all parked ids — with original seqs — at tick end. Each task pops at most once per tick → O(R log R), exact ordering preserved.
Only the exec heap can park. Restore-tier tasks cost ZERO_COST by
construction (5f), so fits is always true for them — a restore never
parks, and no parkedRestore list exists.
5d. Counters, fits, and skip-safety
Section titled “5d. Counters, fits, and skip-safety”const costs = options.resourceCostsconst resourcesActive = costs !== undefined && costs.size > 0const cpuBudget = options.cpuBudget ?? concurrencyconst memBudget = options.memBudget ?? Infinitylet reservedCpu = 0let reservedMem = 0
// Restore-tier tasks cost ZERO regardless of declaration (5f).const costOf = (id: string): ResourceCost => restoreTier?.has(id) ? ZERO_COST : (costs?.get(id) ?? ZERO_COST)
// Zero never blocks; within-budget reserves; over-budget solo-clamps.const fitsAxis = (cost: number, reserved: number, budget: number): boolean => cost === 0 ? true : cost <= budget ? reserved + cost <= budget : reserved === 0
const fits = (id: string): boolean => { const c = costOf(id) return fitsAxis(c.cpu, reservedCpu, cpuBudget) && fitsAxis(c.mem, reservedMem, memBudget)}Skip-safety (critical). A task destined to SKIP (fail-fast, or a
failed/skipped dep with continueMode !== 'always') executes nothing
and must never be parked — else a big-but-doomed task parks forever and
the run hangs. Extract ONE predicate and use it in BOTH the parker and
the tick loop’s skip branch (one implementation, no drift):
const willSkip = (id: string): boolean => { if (failFastTripped) return true if (restoreTier?.has(id)) return false // restores bypass the dep check if (continueMode === 'always') return false const node = nodes.get(id)! return node.deps.some((d) => { const u = outcomes.get(d) return u?.status === 'failed' || u?.status === 'skipped' })}5e. The new tick loop (sketch)
Section titled “5e. The new tick loop (sketch)”const tick = (): void => { if (resolved) return const parked: Array<[string, number]> = [] // exec tier only; local to this tick
// Highest-priority admissible task: exec tier first (misses own the // pool), then restore tier (0-cost, never parks). A would-SKIP task // returns immediately (free); a non-fitting executor parks. const takeFitting = (): string | undefined => { while (execReady.size > 0) { const seq = execReady.peekSeq() const id = execReady.pop()! if (!resourcesActive || willSkip(id) || fits(id)) return id parked.push([id, seq]) } return restoreReady.pop() }
while (active < concurrency) { const id = takeFitting() if (id === undefined) break // nothing ready fits right now const node = nodes.get(id) as TaskNode const upstream = node.deps.map((d) => outcomes.get(d) as TaskOutcome)
// Skip path — the SAME willSkip the parker used; free, no reserve. if (willSkip(id)) { finishOne(id, { node, status: 'skipped', exitCode: 1, durationMs: 0 }) continue }
// Will execute → reserve; capture the cost for a symmetric release. active++ const c = costOf(id) reservedCpu += c.cpu reservedMem += c.mem onStart?.(node) execute(node, upstream) .then((outcome) => { active-- reservedCpu -= c.cpu reservedMem -= c.mem finishOne(id, outcome) tick() }) .catch((err) => { /* failed outcome as today */ active-- reservedCpu -= c.cpu reservedMem -= c.mem finishOne(id, outcome) tick() }) }
// Repush parked ids with ORIGINAL seqs — exact FIFO preserved. for (const [id, seq] of parked) execReady.push(id, seq)
if (outcomes.size === nodes.size && active === 0) { resolved = true resolve(outcomes) }}Properties:
- Default-0 == today.
resourcesActivefalse →takeFittingshort-circuits beforewillSkip/fits, never parks, returns exactly what oldtakeReadyreturned. Byte-identical path. - Release re-drives admission.
reserveddrops in the completion callbacks, which already calltick()— parked tasks re-check immediately. No polling. - No deadlock.
active === 0⇒ both reserved counters are 0 ⇒fitsAxisadmits any ready task (within-budget or solo-clamped). - Exec still owns the pool. Restores are reached only when no exec task is admissible — the two-tier “misses first, restores backfill” contract holds (and improves: a blocked-on-resources exec frontier now lets restores backfill instead of idling).
5f. Restore tier reserves 0
Section titled “5f. Restore tier reserves 0”A restore-tier task is a confirmed local cache hit: its “execution” is a
cheap tar extract, not the task’s real work. costOf short-circuits it
to ZERO_COST, so it fits unconditionally and never holds budget
against a real executor. No change in local-shortcircuit.ts or
execute-task.ts — the declared cost lives on the config; the scheduler
decides when it counts.
6. Wiring (src/orchestrator/run.ts)
Section titled “6. Wiring (src/orchestrator/run.ts)”const cpuBudget = concurrencyconst memBudget = options.memory ?? os.totalmem() // import os from 'node:os'const resourceCosts = resolveResourceCosts(nodes, cpuBudget, memBudget)// …thread into runGraph:...(resourceCosts.size > 0 ? { resourceCosts, cpuBudget, memBudget } : {}),Passing the three fields ONLY when the map is non-empty is the single
gate keeping every current run byte-identical. os.totalmem() runs once
per run.
7. Display (Phase 2 — one static line)
Section titled “7. Display (Phase 2 — one static line)”Keep the worker slots untouched. When (and only when) the cost map is
non-empty, add ONE static budget row to the footer info section
(formatSummarySection, src/orchestrator/summary.ts) via two new
optional RunContext fields:
info 8 workers · local cache · cpu budget 8 · mem budget 16.0 GBMemory formatted by a tiny local formatBytes in summary.ts (the
orchestrator must not import cli/format.ts — module boundary). No live
reserved-sum, no cost-map threading into the logger — deliberately
minimal; a live gauge can come later if the static line proves too
quiet.
8. Wire / delegation
Section titled “8. Wire / delegation”Per-task cpus/memory need no wire field: a delegated run
re-loads and re-evaluates every project config server-side, so
reservations are recomputed where the tasks execute. The only addition
is the run-level --memory override (an explicit user cap that should
be authoritative wherever the run executes, like concurrency/timeout):
RunOptions.memory?: number(resolved bytes; per-run knob, never folded into a key).RunRequest.memory?: number+ one line in each protocol mapper.
The default budget (os.totalmem()) resolves on the executing side —
the correct machine’s RAM — and an explicit --memory wins end-to-end.
9. CLI (src/cli/run.ts, help.ts)
Section titled “9. CLI (src/cli/run.ts, help.ts)”--memory <size>/--memory=<size>, modeled on--timeout: value throughparseSize(now inutil);null→--memory must be a size like 8GB or 512MB; missing value → error.RunArgs.memory→resolveRunOptionssetsopts.memory.- Help: the flag, the container caveat, and a note that reservations are
declared per task in config (
exec.resources), not via CLI flags.
Test list (the implementation contract)
Section titled “Test list (the implementation contract)”Resolver — tests/resources.test.ts (new):
resolveCpu: 8→8; "50%"@8→4; "150%"@8→12 (solo-clamp territory);
"12.5%" fractional; undefined/0→0. resolveMem: bytes, "2GB",
"512MB", "50%"@16 GiB, undefined→0. resolveResourceCosts returns
an EMPTY map when nothing declares.
Loader — tests/project-loader.test.ts:
accept cpus: 2 | "50%" | 0.5, memory: 1024 | "512MB" | "25%";
reject cpus: -1 | NaN | "%" | "2GB", memory: -1 | "5X" | "1.5GB" | "%".
Key stability — tests/task-hash.test.ts / cache.test.ts:
no-declaration config hashes byte-identically (fast-path pin); a config
hashes the SAME with/without cpus/memory, and changing them doesn’t
change the hash.
Scheduler — tests/scheduler.test.ts:
- undefined/empty
resourceCosts⇒ order byte-identical (legacy pin; existing suite must pass unchanged). - CPU axis: two
cpus:4@ budget 8 run concurrently; twocpus:5serialize. Memory axis analogous. - Combined: fits-CPU-but-not-memory waits until memory frees.
- Backfill: running
cpus:6, headcpus:4parks, lower-prioritycpus:2admits. - Solo-clamp / no-deadlock:
cpus:16@ 8 runs alone from idle; acpus:1waits while acpus:0runs alongside; an all-cpus:16graph completes one-at-a-time. - Skip-safety: a too-big task whose dep failed SKIPS (doesn’t park forever); run terminates.
- Restore tier reserves 0: a restore declaring
cpus:8admits alongside a runningcpus:8executor. - FIFO-among-equals: equal-priority fitting tasks admit in enqueue order even after a sibling parked and repushed (original-seq pin).
CLI / wire — tests/cli.test.ts + protocol round-trip:
--memory 8GB / =512MB → bytes; missing/invalid → error;
optionsToRequest/requestToOptions round-trip memory.
File touch list
Section titled “File touch list”src/config.ts · src/util/index.ts (+cli/cache.ts re-export) ·
src/orchestrator/resources.ts (new) · src/workspace/project-loader.ts ·
src/orchestrator/task-hash.ts · src/graph/scheduler.ts (+graph/index.ts
re-export) · src/orchestrator/run.ts · src/orchestrator/options.ts ·
src/orchestrator/protocol.ts · src/cli/{run,help}.ts ·
src/orchestrator/summary.ts (Phase 2) · docs (schema.md, cli.md,
caching.md) · tests as listed.
Phasing (each independently shippable)
Section titled “Phasing (each independently shippable)”- Phase 1 — the feature (local runs): schema + loader + resolver +
parseSizemove + key strip + scheduler admission +run.tswiring +--memory+ tests. - Phase 2 — display: the gated static budget line.
- Phase 3 — delegation:
RunRequest.memory+ mappers.
Non-goals
Section titled “Non-goals”- Hard enforcement (cgroups / rlimits /
nice) — hints only. - GPU / custom resources — only
cpusandmemoryship. The groupedexec.resourcesobject is exactly where a third axis would land later (a new key + a third admission axis), but none ships now — the loader rejects unknown keys. - Core affinity / pinning / NUMA; load-aware probing beyond the concurrency default.
- Retro-stripping
timeout/retriesfrom the key (needs aCACHE_VERSIONbump; deferred).
Decided (previously open)
Section titled “Decided (previously open)”- Persistent tasks honor their reservation for their lifetime (a dev
server genuinely holds RAM); switchable in
costOfif it starves short tasks. - Phase-2 display is the static budget line only — no live reserved-sum threading into the logger.
- Two load-bearing details for the implementer: (1) the
parseSizemove toutil(module boundary), and (2) skip-safety —takeFittingmust return a would-skip task without a fit check, or a too-big doomed task parks forever and the run hangs.