@vzn/vx-schedule-history
v0.0.646
Published
History-based scheduling for vx — a `schedule` plugin that orders ready tasks by their expected remaining critical path, learned from the local run history.
Maintainers
Readme
@vzn/vx-schedule-history
Website: https://vznjs.github.io/vx/ · this plugin: https://vznjs.github.io/vx/plugins/vx-schedule-history/ · source: https://github.com/vznjs/vx
History-based scheduling for @vzn/vx: a schedule plugin that orders ready tasks by their expected REMAINING critical-path duration — a task's own p50 plus the longest chain of dependents behind it — learned from the workspace's local run history. Zero dependencies.
A task's p50 is the median of its last five timings on the current branch, else on any branch, else unknown. The timings are shared by every worktree of the repo, so a new worktree orders by them on its first run.
Usage
bun add -d @vzn/vx @vzn/vx-schedule-history// .vx/workspace.ts
import { scheduleHistoryPlugin } from '@vzn/vx-schedule-history'
export default { plugins: [scheduleHistoryPlugin()] }scheduleHistoryPlugin({ window?, assume?, file?, defaultBranch?, resources?, memory?, reservations? }) learns from the last window invocations (default 20) through core's LocalHistoryProvider over the cache's own SQLite handle. With one worker and two chains of identical shape, the chain history says is slow starts first; with no history yet, the scheduler's structural order stands.
assume is for the run that has no history: a fresh CI runner. It names durations (task id → ms) for tasks the history has not seen, so a long leaf task — a docs build, a cross-compile — starts first instead of last:
export default {
plugins: [scheduleHistoryPlugin({ assume: { '@vzn/vx-docs#build': 30_000 } })],
}A recorded p50 always wins over an assumption, and assumptions never feed the workspace median: they are a hint for the cold run, not evidence. A duration that is not a finite non-negative number (Number() of an unset variable is NaN) is dropped with one warning naming its task ids; the rest still order the run. vx's own CI declares four, this one among them — its docs build was the 29 s tail of a 99 s cold gate, ready from the second second and started last (2026-09-10).
Timings through the cache
A fresh CI runner or a new branch has no timings of its own. With a remote cache declared, the plugin carries them there, with nothing to configure:
- Write. A CI run writes each task's p50 under its commit after the run; on a branch that lands under the branch's cache scope. A commit's timings are written once and never replaced; a read-only cache policy keeps them local. A local run never writes.
- Read. When some task has no timing on this branch, the plugin
takes the merge-base of HEAD with
defaultBranch(ororigin/<defaultBranch>) and imports the newest timings at it or its 4 first-parent ancestors, once per merge-base for all its worktrees. On a branch it then reads HEAD's parent's timings from the branch's scope, once per commit: a task there overrides the default branch's, and a task missing there keeps it. The run never waits for them: it starts in the order local history gives, and when the timings land (within 500 ms) the tasks not yet started re-rank. A run whose tasks all have timings on its branch spawns no git and asks no cache. - Order. A forecast reads this branch's last five timings first,
then the newest imported one, then any branch's, then
assume. - Untrusted. A blob over 1 MiB, not
{ version: 1, tasks }, with more than 50 000 tasks, or with a time outside 0 to 24 h, is dropped, silently; ids not in the run's graph are dropped. It can only order a run, never change what runs. The first write of a commit's blob stays, so the cache's write credential is the boundary: give PR jobs a read-only token.
Timings between CI runners, without a remote cache
A fresh CI runner has no history. file names a JSON file, from the
workspace root, that carries it: read before ordering, rewritten after
each run with every task's p50 (its directory made if absent), so a
CI cache keeps one file.
export default {
plugins: [scheduleHistoryPlugin({ file: '.vx-timings.json' })],
}# GitHub Actions
- uses: actions/cache@v4
with:
path: .vx-timings.json
key: vx-timings-${{ github.run_id }}
restore-keys: vx-timings-The file is { "version": 1, "tasks": { "<task id>": <ms> } }, keys
sorted. A p50 recorded on this machine wins over it, and it wins over
assume; like assume, it never feeds the workspace median. A task the
run did not see keeps its time. A file that is not one warns and is read
as empty; --dry writes nothing.
Reservations learned from history
The same history answers what each task used: the runner records every
execution's CPU time and peak RSS. Core's only gate is the worker count;
this plugin's admit hook packs the finer numbers, and core never
learns them. Each task's reservation is the largest peak RSS in the
window times a headroom (1.25), rounded up to 64 MB; cores are never
learned (the parallelism a build shows is what the machine let it have,
and reserving the solo reading packed 92 real builds two wide on four
cores for a 21% longer run, 2026-09-15), only declared. A task is
admitted while its reservation and those of everything running beside
it fit the budgets —
cores are the run's worker count, memory is what this process may use
(the machine's total, capped by the cgroup limit a container runs
under) — and a task over a whole budget runs alone. A task with no execution in the
window reserves nothing and runs freely, as it would with no plugin; a
spike or a refactor ages out with the window. A cache hit counts too:
the producing execution's usage rides the artifact, so a fresh CI runner
that restored a task from a remote cache has its reservation on the
next run without ever having executed it.
plugins: [
scheduleHistoryPlugin({
// MB. The default is the machine's total capped by the cgroup limit; set it to budget below either.
memory: 8192,
resources: { headroom: 1.5 },
// Declared by hand for what history cannot size (a first run); a declaration wins.
reservations: { 'app#e2e': { cpus: 2, memory: 4096 } },
}),
]window, memory and headroom must each be a finite number above 0 (window a whole one). One that is not — Number(process.env.X) with X unset is NaN, and no reservation fits a NaN budget, so every task that reserved memory would wait for an idle machine — runs on its default, named in one warning per run and in vx history. So does a declared reservations axis (cpus, memory): one that is not a number above 0 reserves nothing on that axis (a NaN held its task alone and every reserving task beside it waited); 0 is taken as nothing, silently. An entry that is not an object ({ "app#build": 2048 }) reserves nothing and is named the same way.
vx history [--format pretty|json] (a verb this plugin adds; --help
prints its usage) shows what it learned per task and the reservation it
will pack, with the budgets it packs into:
$ vx history
history: last 20 runs · budgets 4 cores (the default worker count; --concurrency changes it per run) · 13567 MB (what this process may use)
task runs p50 peak rss cpu reserves
app#build 12 8.41s 612 MB 1.9× 768 MB
app#e2e 3 41.20s 1.4 GB 1.1× 4096 MB · 2 cores (declared)
lib#test 12 1.02s — 1.0× —
3 tasks with no execution in the window reserve nothing--format json emits { window, budgets, tasks } with every task's
runs, p50DurationMs, maxPeakRssBytes, maxCpuParallelism,
reservation and declared; schemas/history.json (shipped) is its
JSON Schema, held to the output by core's
tests/plugin-json-schemas.unsafe.test.ts. A cache hit's row carries the producing
execution's usage, so a task restored from a remote cache shows a
reservation on a machine that never executed it. A task lighter than
vx itself has no peak on record (the runner reports one only above its
own footprint, since Linux hands a lighter child the parent's figure)
and reserves nothing — what a task under vx's own footprint needs.
resources: false turns the learning off (declared reservations are
still packed). Admission control, not enforcement: nothing is
cgroup-limited or reniced, and a task that exceeds its reservation is
the job of exec.timeout and the OS. Design:
docs/design/resource-estimates-2026-09.md in @vzn/vx.
What it does and does not do
- It is an ORDERING hint over the scheduler's structural baseline (how many tasks wait on each one, directly or through others), merged as
VxPlugin.schedulereturns it: task id → weight. It never changes what runs, only which ready task runs first. - Cache hits are not modelled as zero-cost: predicting cache state needs the key and a probe, which the scheduler handles at run time (a confirmed hit is backfill, never a critical-path task). The estimate is "what if everything ran", which is exactly the case where order matters.
- It fails open: a broken history read warns (
[vx] schedule-history: ordering falls back to the baseline) and leaves the baseline order and only the declared reservations. Observability never breaks a run. - Cost: one history read per run, serving both hooks, paid only by workspaces that declare the plugin. Core applies no plugin by default.
criticalPathPriorities(nodes, history, assume?), resourceEstimates(nodes, history, headroom), withDeclared(learned, declared) and admits(id, running, reservations, budgets) are exported for tests and for policies that want the same scoring over another history source, with the types ScheduleHistoryOptions, ResourceEstimate and Budgets.
History
Core's opt-in predictive mode until 2026-09-02, then core's one bundled plugin (@vzn/vx/plugins/schedule-history) until 2026-09-10, when it moved here so core ships no plugin at all.
