@vzn/vx-history 0.0.0-stage → 0.0.648

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,887 @@
1
+ // History-based scheduling as a plugin: order ready tasks by their expected
2
+ // REMAINING critical-path duration (own p50 + the longest chain of
3
+ // dependents), learned from the local run history. This was core's opt-in
4
+ // `predictive` mode until 2026-09-02 and core's one bundled plugin until
5
+ // 2026-09-10; it is its own package now — declared like any other, paying
6
+ // its history read only in workspaces that ask for it, and core ships no
7
+ // plugin at all.
8
+ //
9
+ // Cache hits are NOT modelled as zero-cost: predicting cache state needs
10
+ // the key and a probe, which the scheduler handles at run time (a confirmed
11
+ // hit is backfill, never a critical-path task). The estimate here is "what
12
+ // if everything ran", which is exactly the case where order matters.
13
+
14
+ import {
15
+ currentBranch,
16
+ definePlugin,
17
+ LocalHistoryProvider,
18
+ machineMemoryBytes,
19
+ Cache,
20
+ type HistoryTable,
21
+ type TaskNode,
22
+ type TaskResources,
23
+ type VxPlugin,
24
+ loadResolvedProjects,
25
+ criticalPath,
26
+ taskDefinitions,
27
+ UserError,
28
+ refuseUnknownOptions,
29
+ ui,
30
+ type PluginOptionKinds,
31
+ } from '@vzn/vx'
32
+ import type { CommandContext } from '@vzn/vx'
33
+ import path from 'node:path'
34
+ import type { HistoryRow } from './history-view.js'
35
+ import type { HistoryServer, Named, ServerTiming } from './history-server.js'
36
+
37
+ export interface VxHistoryOptions {
38
+ /** How many recent invocations to learn from. Default 20. */
39
+ readonly window?: number
40
+ /**
41
+ * Pack tasks by what their last `window` executions actually used
42
+ * (`admit` stage): a task's reservation is the largest peak RSS seen
43
+ * times `headroom`, rounded up to 64 MB. Cores are never learned (see
44
+ * `resourceEstimates`), only declared in `reservations`. A task is
45
+ * admitted while the reservations of everything running beside it fit
46
+ * the budgets, and a task over a whole budget runs alone. A task with no execution in the window
47
+ * reserves nothing and runs freely. `false` turns packing off.
48
+ * Default `{ headroom: 1.25 }`.
49
+ */
50
+ readonly resources?: false | { readonly headroom?: number }
51
+ /**
52
+ * Memory budget in megabytes the reservations pack against. Default:
53
+ * what this process may use — the machine's total, capped by the
54
+ * cgroup limit a container runs under (`os.totalmem()` alone reports
55
+ * the HOST's RAM there). Pass it to budget below either.
56
+ */
57
+ readonly memory?: number
58
+ /**
59
+ * Reservations declared by hand (task id → cores, megabytes) for the
60
+ * task history cannot size: a first run, or a task whose peak the
61
+ * runner cannot see. A declared axis wins over the learned one; an axis
62
+ * left out keeps it.
63
+ */
64
+ readonly reservations?: Readonly<Record<string, ResourceEstimate>>
65
+ /**
66
+ * Durations (task id → ms) to assume for tasks the history has not seen
67
+ * yet. A fresh CI runner has no history at all, and there the baseline
68
+ * order starts a long leaf task last — the one place the scheduler's
69
+ * structural tie-break is worst. A recorded p50 always wins over an
70
+ * assumption, and assumptions never feed the workspace median: they are
71
+ * a hint for the cold run, not evidence.
72
+ */
73
+ readonly assume?: Readonly<Record<string, number>>
74
+ /**
75
+ * A timings file, from the workspace root, that carries the learned
76
+ * durations between machines: read before ordering, rewritten after the
77
+ * run with each task's p50 (task id → ms), so CI caches one file. A
78
+ * recorded p50 here wins over it, and it wins over `assume`; like
79
+ * `assume`, it never feeds the workspace median.
80
+ */
81
+ readonly file?: string
82
+ /**
83
+ * A vx-history server's https URL (`@vzn/vx-history-server`; http only to
84
+ * this machine), or `VX_HISTORY_URL`. On a CI job with a token (GitHub's
85
+ * OIDC, or GitLab's
86
+ * `VX_HISTORY_TOKEN`) each run asks it for its tasks' numbers, without
87
+ * waiting, and sends its own timings once at its end.
88
+ */
89
+ readonly url?: string
90
+ /**
91
+ * Headers every request to `url` carries, for a proxy in front of it
92
+ * (`{ 'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID }`, an
93
+ * Access service token, 2026-10-25). An unset value is left out;
94
+ * `authorization` stays the CI token's.
95
+ */
96
+ readonly headers?: Record<string, string | undefined>
97
+ /**
98
+ * The CI issuer whose tokens `url` takes, as they name it
99
+ * (`https://token.actions.githubusercontent.com`), and the repository at
100
+ * it, `org/name`: required with a server. The run's query names them
101
+ * (2026-10-25: the user names them, nothing guesses).
102
+ */
103
+ readonly issuer?: string
104
+ readonly repository?: string
105
+ }
106
+
107
+ // The provider's own default (50) serves `--dry` predictions; an ordering
108
+ // hint needs less and reads a 2.5× smaller slice of the run history.
109
+ const DEFAULT_WINDOW = 20
110
+
111
+ // Each option is read in ONE place that a run's hooks and `vx history`
112
+ // share: two copies of each let either drift unseen (item 805).
113
+ const windowOf = (options: VxHistoryOptions): number =>
114
+ usable(options.window, true) ? options.window : DEFAULT_WINDOW
115
+
116
+ /**
117
+ * A number option's value when it is one: finite and above zero (a whole
118
+ * number where `integer`). `memory: Number(process.env.X)` with X unset
119
+ * is NaN, and against a NaN budget no reservation ever fits, so every
120
+ * task that reserved memory waited for an idle machine: the run went
121
+ * serial and said nothing. A NaN `headroom` dropped every learned
122
+ * reservation. Each bad value is the default instead, named by
123
+ * `numberWarnings`, as `assume` does (item 930).
124
+ */
125
+ function usable(v: unknown, integer = false): v is number {
126
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 && (!integer || Number.isInteger(v))
127
+ }
128
+
129
+ /** One warning naming every number option that is not one, and the default it runs on. */
130
+ function numberWarnings(options: VxHistoryOptions, warn: (m: string) => void): void {
131
+ const bad: string[] = []
132
+ const check = (name: string, v: unknown, fallback: string, integer = false): void => {
133
+ if (v !== undefined && !usable(v, integer)) {
134
+ bad.push(
135
+ `${name} ${typeof v === 'number' ? String(v) : JSON.stringify(v)} (using ${fallback})`,
136
+ )
137
+ }
138
+ }
139
+ check('window', options.window, String(DEFAULT_WINDOW), true)
140
+ check('memory', options.memory, 'what this process may use')
141
+ if (options.resources !== false && options.resources !== undefined) {
142
+ check('resources.headroom', options.resources.headroom, String(DEFAULT_HEADROOM))
143
+ }
144
+ for (const [id, r] of Object.entries(options.reservations ?? {})) {
145
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) {
146
+ bad.push(
147
+ `reservations[${JSON.stringify(id)}] ${JSON.stringify(r) ?? String(r)} (using what history learned; must be { cpus?, memory? })`,
148
+ )
149
+ continue
150
+ }
151
+ for (const axis of ['cpus', 'memory'] as const) {
152
+ const v = r[axis]
153
+ if (v !== 0) check(`reservations[${JSON.stringify(id)}].${axis}`, v, 'what history learned')
154
+ }
155
+ }
156
+ if (bad.length > 0) {
157
+ warn(`[vx] vx-history: ignores ${bad.join(', ')} — each must be a finite number above 0`)
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The declared reservations, each axis only where it is a number above 0,
163
+ * or 0: a declared nothing, over what history learned. A NaN
164
+ * (`Number(process.env.X)`, X unset) held a task alone and every
165
+ * reserving task beside it waited — the NaN summed into what runs — so the
166
+ * run went serial and said nothing (G-4's class). A bad axis is not
167
+ * declared, named by `numberWarnings`.
168
+ */
169
+ function declaredOf(
170
+ options: VxHistoryOptions,
171
+ ): Readonly<Record<string, ResourceEstimate>> | undefined {
172
+ if (options.reservations === undefined) return undefined
173
+ const out: Record<string, ResourceEstimate> = {}
174
+ for (const [id, r] of Object.entries(options.reservations)) {
175
+ const e: { cpus?: number; memory?: number } = {}
176
+ if (r?.cpus === 0 || usable(r?.cpus)) e.cpus = r.cpus
177
+ if (r?.memory === 0 || usable(r?.memory)) e.memory = r.memory
178
+ out[id] = e
179
+ }
180
+ return out
181
+ }
182
+
183
+ const headroomOf = (options: VxHistoryOptions): number => {
184
+ const h = options.resources === false ? undefined : options.resources?.headroom
185
+ return usable(h) ? h : DEFAULT_HEADROOM
186
+ }
187
+
188
+ // The schedule reads no rates (`resourcesFor`); `vx history` shows runs.
189
+ const readHistory = (
190
+ cache: Cache,
191
+ ids: readonly string[],
192
+ options: VxHistoryOptions,
193
+ branch: string | null,
194
+ defs: ReadonlyMap<string, string>,
195
+ ): Promise<ReadonlyMap<string, TaskResources>> =>
196
+ new LocalHistoryProvider(cache.dbHandle(), windowOf(options), branch, defs).resourcesFor(ids)
197
+
198
+ /** What each task reserves: learned from the history unless `resources: false`, a declared reservation over either. */
199
+ const reservationsFor = (
200
+ ids: Iterable<string>,
201
+ table: ReadonlyMap<string, TaskResources>,
202
+ options: VxHistoryOptions,
203
+ ): ReadonlyMap<string, ResourceEstimate> =>
204
+ withDeclared(
205
+ options.resources !== false ? estimatesFor(ids, table, headroomOf(options)) : new Map(),
206
+ declaredOf(options),
207
+ )
208
+
209
+ const memoryBudgetMb = (options: VxHistoryOptions): number =>
210
+ usable(options.memory) ? options.memory : Math.floor(machineMemoryBytes() / MB)
211
+
212
+ /**
213
+ * The `assume` durations that are numbers from 0 to 2^53: a larger one
214
+ * summed along a chain to Infinity, which core refuses too. A NaN
215
+ * (`Number(process.env.X)` with X unset) became a NaN weight and a string
216
+ * (an untyped `.mjs` config) string weights, and core refused both as a
217
+ * UserError: an ordering hint failed the run (item 930). The rest are
218
+ * dropped by name, as a broken history read costs only the ordering.
219
+ */
220
+ function assumptions(
221
+ options: VxHistoryOptions,
222
+ warn: (m: string) => void,
223
+ ): Readonly<Record<string, number>> {
224
+ const ok: Record<string, number> = {}
225
+ const bad: string[] = []
226
+ for (const [id, ms] of Object.entries(options.assume ?? {})) {
227
+ if (isDuration(ms)) ok[id] = ms
228
+ else bad.push(id)
229
+ }
230
+ if (bad.length > 0) {
231
+ warn(
232
+ `[vx] vx-history: assume ignores ${bad.map((id) => JSON.stringify(id)).join(', ')} — each must be a number of ms from 0 to 2^53`,
233
+ )
234
+ }
235
+ return ok
236
+ }
237
+
238
+ /**
239
+ * A duration a task can take, in ms. A chain's weight is its durations
240
+ * summed, and two `1e308`s (a timings file a CI cache handed over, or
241
+ * `assume`) summed to Infinity, which core refuses: the run failed in
242
+ * `schedule`. Under 2^53 ms, any chain's sum stays finite.
243
+ */
244
+ const isDuration = (ms: unknown): ms is number =>
245
+ typeof ms === 'number' && ms >= 0 && ms <= Number.MAX_SAFE_INTEGER
246
+
247
+ /** The timings file's durations a run can take; the rest are not carried. */
248
+ function carriedOf(read: Readonly<Record<string, number>>): Record<string, number> {
249
+ const out: Record<string, number> = {}
250
+ for (const [id, ms] of Object.entries(read)) if (isDuration(ms)) out[id] = ms
251
+ return out
252
+ }
253
+
254
+ /** Each option `VxHistoryOptions` names, with its kind: derived from the type, so the two cannot drift. */
255
+ const VX_HISTORY_KEYS: PluginOptionKinds<VxHistoryOptions> = {
256
+ window: 'number',
257
+ resources: 'any',
258
+ memory: 'number',
259
+ reservations: 'object',
260
+ assume: 'object',
261
+ file: 'string',
262
+ url: 'string',
263
+ headers: 'object',
264
+ issuer: 'string',
265
+ repository: 'string',
266
+ }
267
+
268
+ /** `vxHistory({ issuer, repository })`, checked; the server's requests name them. */
269
+ function namedOf(issuer: unknown, repository: unknown): Named {
270
+ if (typeof issuer !== 'string' || !/^https?:\/\/\S+$/.test(issuer))
271
+ throw new UserError(
272
+ `vxHistory(): issuer must be the CI issuer's URL (https://token.actions.githubusercontent.com), got ${String(issuer)}`,
273
+ )
274
+ if (typeof repository !== 'string' || !/^[^/\s]+(\/[^/\s]+)+$/.test(repository))
275
+ throw new UserError(`vxHistory(): repository must be org/name, got ${String(repository)}`)
276
+ return { issuer: issuer.replace(/\/$/, ''), repository: repository.toLowerCase() }
277
+ }
278
+
279
+ /**
280
+ * https, or http to this machine: every request carries the CI token, and
281
+ * http:// to another host sent it in clear.
282
+ */
283
+ function secure(url: string): boolean {
284
+ if (/^https:\/\/\S/.test(url)) return true
285
+ if (!/^http:\/\/\S/.test(url)) return false
286
+ const host = URL.parse(url)?.hostname
287
+ return host === '127.0.0.1' || host === '[::1]' || host === 'localhost'
288
+ }
289
+
290
+ /** `VX_HISTORY_URL` under the option, trailing slashes dropped; undefined when neither names one. */
291
+ function serverUrlOf(
292
+ option: string | undefined,
293
+ env: Record<string, string | undefined> = Bun.env,
294
+ ): string | undefined {
295
+ const url = (option ?? env.VX_HISTORY_URL)?.replace(/\/+$/, '')
296
+ if (url === undefined || url === '') return undefined
297
+ // Named as the workspace set it: a bad VX_HISTORY_URL was refused as `url`.
298
+ const from = option === undefined ? 'VX_HISTORY_URL' : 'url'
299
+ if (!secure(url))
300
+ throw new UserError(
301
+ `vxHistory(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${url}`,
302
+ )
303
+ // Each route is the server's /v1/…: one that names /v1 asked /v1/v1/…, and every answer was a 404.
304
+ if (url.endsWith('/v1'))
305
+ throw new UserError(
306
+ `vxHistory(): ${from} is the server's base, without /v1: ${url.slice(0, -3)}`,
307
+ )
308
+ return url
309
+ }
310
+
311
+ /**
312
+ * The names a request sets itself: one of them in `headers`, in another
313
+ * case, was joined to the request's own value, and the server refused it.
314
+ */
315
+ const WIRE_HEADERS = new Set(['authorization', 'content-type', 'content-length'])
316
+
317
+ /** `vxHistory({ headers })` with unset values left out; a name or value no header can carry, or `authorization`, refused, never printing a value. */
318
+ function proxyHeaders(
319
+ headers: Record<string, string | undefined> | undefined,
320
+ ): Record<string, string> {
321
+ const out: Record<string, string> = {}
322
+ for (const [name, value] of Object.entries(headers ?? {})) {
323
+ if (value === undefined) continue
324
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || WIRE_HEADERS.has(name.toLowerCase()))
325
+ throw new UserError(`vxHistory() option "headers" cannot set "${name}"`)
326
+ if (typeof value !== 'string' || /[\r\n\0]/.test(value))
327
+ throw new UserError(
328
+ `vxHistory() option "headers.${name}" must be one line of text (its value is not printed)`,
329
+ )
330
+ out[name] = value
331
+ }
332
+ return out
333
+ }
334
+
335
+ export function vxHistory(options: VxHistoryOptions = {}): VxPlugin {
336
+ refuseUnknownOptions('vxHistory()', options, VX_HISTORY_KEYS)
337
+ const named =
338
+ options.url === undefined && options.issuer === undefined && options.repository === undefined
339
+ ? undefined
340
+ : namedOf(options.issuer, options.repository)
341
+ // At the call: refused once a server was named, a laptop passed what
342
+ // failed the first CI run.
343
+ const headers = proxyHeaders(options.headers)
344
+ // `'any'` above lets `null` through, and every run then failed in
345
+ // `schedule` reading `null.headroom`.
346
+ const { resources } = options
347
+ if (
348
+ resources !== undefined &&
349
+ resources !== false &&
350
+ (resources === null || typeof resources !== 'object' || Array.isArray(resources))
351
+ ) {
352
+ throw new UserError(
353
+ `vxHistory() option "resources" must be false or an object, got ${typeof resources === 'number' ? String(resources) : (JSON.stringify(resources) ?? String(resources))}`,
354
+ )
355
+ }
356
+ // A misspelt key inside was read as unset, unsaid. Values stay `any`:
357
+ // a bad number warns per run (`numberWarnings`), as at the top level.
358
+ if (resources !== undefined && resources !== false) {
359
+ refuseUnknownOptions('vxHistory() option "resources"', resources, { headroom: 'any' })
360
+ }
361
+ for (const [id, r] of Object.entries(options.reservations ?? {})) {
362
+ if (r !== null && typeof r === 'object' && !Array.isArray(r)) {
363
+ refuseUnknownOptions(`vxHistory() option "reservations[${JSON.stringify(id)}]"`, r, {
364
+ cpus: 'any',
365
+ memory: 'any',
366
+ })
367
+ }
368
+ }
369
+ // Checked here, not in a run's `setup`: `vx show` and a `--dry` plan
370
+ // took a url no request can carry, and every run failed on it.
371
+ if (options.url !== undefined) serverUrlOf(options.url)
372
+ // `''` resolved to the workspace root: each run warned it could not read
373
+ // a directory and failed in teardown renaming its timings over it.
374
+ if (options.file !== undefined && options.file.trim() === '') {
375
+ throw new UserError(
376
+ `vxHistory() option "file" must be a path, got ${JSON.stringify(options.file)}`,
377
+ )
378
+ }
379
+ const hooks: Parameters<typeof definePlugin>[1] = {
380
+ commands: {
381
+ history: {
382
+ description:
383
+ 'What vx-history learned per task (p50, peak RSS, CPU parallelism) and the reservation it packs.',
384
+ run: (argv, ctx) => historyCmd(argv, ctx, options),
385
+ },
386
+ },
387
+ // Core calls it once per run, so the history is read once per run, and
388
+ // a second run in the same process (`vx watch`) reads its own.
389
+ async schedule(nodes, ctx) {
390
+ numberWarnings(options, ctx.warn)
391
+ const file =
392
+ options.file === undefined ? undefined : path.resolve(ctx.workspaceRoot, options.file)
393
+ // Loaded with the option: every run evaluates the workspace config.
394
+ const carried =
395
+ file === undefined
396
+ ? {}
397
+ : carriedOf((await import('./timings-file.js')).readTimings(file, ctx.warn))
398
+ const defs = taskDefinitions(nodes.values())
399
+ if (file !== undefined)
400
+ scheduled = {
401
+ file,
402
+ carried,
403
+ cache: ctx.localCache,
404
+ ids: [...nodes.keys()],
405
+ branch: ctx.branch,
406
+ defs,
407
+ }
408
+ // Only a task that runs a command has a timing: a group asked for
409
+ // read as a task with no history on this branch, so every run
410
+ // tried a default-branch import (and paid a query per group).
411
+ const ids: string[] = []
412
+ for (const n of nodes.values()) if (n.config.exec?.command !== undefined) ids.push(n.id)
413
+ let table: ReadonlyMap<string, TaskResources>
414
+ let provider: LocalHistoryProvider
415
+ try {
416
+ provider = new LocalHistoryProvider(
417
+ ctx.localCache.dbHandle(),
418
+ windowOf(options),
419
+ ctx.branch,
420
+ defs,
421
+ )
422
+ table = await provider.resourcesFor(ids)
423
+ } catch (err) {
424
+ // Failing open: a broken history read costs the ordering and the
425
+ // learned reservations, never the run.
426
+ ctx.warn(
427
+ `[vx] vx-history: ordering falls back to the baseline: ${err instanceof Error ? err.message : String(err)}`,
428
+ )
429
+ return undefined
430
+ }
431
+ reservations = reservationsFor(nodes.keys(), table, options)
432
+ // Once a run: the server's numbers order it a second time.
433
+ const assumed = { ...assumptions(options, ctx.warn), ...carried }
434
+ const order = (t: ReadonlyMap<string, TaskResources>): ReadonlyMap<string, number> =>
435
+ criticalPath([...nodes.values()], t, assumed)
436
+ // With a history server, its numbers re-rank what has not started
437
+ // when they land; the run never waits on them.
438
+ remote ??= await serverOf()
439
+ // The run's refs, as a remote cache reads them: its branch, the
440
+ // branch it merges into, the default branch (`baseBranch`). `main`
441
+ // was assumed, so a `master` or `baseBranch: 'trunk'` repository
442
+ // read no trunk's numbers; a run with none asks nothing.
443
+ const refs = ctx.refs
444
+ if (remote !== undefined && refs.length > 0) {
445
+ const server = remote
446
+ let late = false
447
+ stopImport = () => (late = true)
448
+ const weights = (async () => {
449
+ const hashes = [...new Set(ids.flatMap((id) => defs.get(id) ?? []))]
450
+ const got = await server.query([...new Set(refs)], hashes)
451
+ const timings = new Map<string, number>()
452
+ const needs = new Map<string, TaskResources>()
453
+ for (const id of ids) {
454
+ const t = got.get(defs.get(id) ?? '')
455
+ if (t === undefined) continue
456
+ timings.set(id, t.durationMs)
457
+ needs.set(id, {
458
+ p50DurationMs: t.durationMs,
459
+ ...(t.cores === undefined ? {} : { maxCpuParallelism: t.cores }),
460
+ ...(t.rssBytes === undefined ? {} : { maxPeakRssBytes: t.rssBytes }),
461
+ })
462
+ }
463
+ // A run that ended first writes nothing: its cache is closing.
464
+ if (timings.size === 0 || late) return undefined
465
+ const sha = `vx-history-server:${Bun.hash(JSON.stringify([...got])).toString(16)}`
466
+ if (!provider.hasImported(sha)) provider.importTimings(sha, timings, needs, defs)
467
+ const fresh = await provider.resourcesFor(ids)
468
+ reservations = reservationsFor(nodes.keys(), fresh, options)
469
+ return order(fresh)
470
+ })().catch((err: unknown) => {
471
+ if (!late) {
472
+ ctx.warn(
473
+ `[vx] vx-history: the timings of the history server at ${server.url} were not applied: ${err instanceof Error ? err.message : String(err)}`,
474
+ )
475
+ }
476
+ return undefined
477
+ })
478
+ ctx.refine(weights)
479
+ }
480
+ return order(table)
481
+ },
482
+ }
483
+ // The run's history server, when one is named and the job has a token.
484
+ let remote: HistoryServer | undefined
485
+ // Loaded only when a server is named: every run evaluates the config.
486
+ const serverOf = async (): Promise<HistoryServer | undefined> => {
487
+ if ((options.url ?? Bun.env.VX_HISTORY_URL ?? '') === '') return undefined
488
+ // VX_HISTORY_URL alone: the workspace names no repository to ask for,
489
+ // so the URL is never read; parsed first, a malformed one failed the run.
490
+ if (named === undefined) return undefined
491
+ const m = await import('./history-server.js')
492
+ const url = serverUrlOf(options.url)
493
+ if (url === undefined) return undefined
494
+ return new m.HistoryServer(url, named, Bun.env, fetch, headers)
495
+ }
496
+ // What the run sends the history server at its end, by task definition.
497
+ let reported: Reported | undefined
498
+ // The upload, once the run has ended.
499
+ let uploading: Upload | undefined
500
+ // Stops the server's answer from landing once the run is over: no row
501
+ // lands after the cache closes.
502
+ let stopImport: (() => void) | undefined
503
+ // The timings file is written for a run only: `schedule` also orders a
504
+ // `--dry` plan, which has no `setup` and records nothing.
505
+ let scheduled:
506
+ | {
507
+ file: string
508
+ carried: Readonly<Record<string, number>>
509
+ cache: Cache
510
+ ids: string[]
511
+ branch: string | null
512
+ defs: ReadonlyMap<string, string>
513
+ }
514
+ | undefined
515
+ let inRun = false
516
+ // A run that executed nothing and restored only tasks the file already
517
+ // times leaves it as it was: an all-cached CI run re-reads no history.
518
+ let changed = false
519
+ let fileWarn: { root: string; warn: (m: string) => void } | undefined
520
+ hooks.setup = async (ctx) => {
521
+ remote ??= await serverOf()
522
+ if (remote?.writes === true) {
523
+ const { timingOf: timing } = await import('./history-server.js')
524
+ // Only the run that ran the tasks hears them: under DTE, the
525
+ // orchestrator, with each worker's measurements.
526
+ const timings = new Map<string, ServerTiming>()
527
+ const sent = (reported = { timings, warn: ctx.warn })
528
+ const server = remote
529
+ ctx.on('onTaskComplete', (node, outcome) => {
530
+ if (node.config.exec?.command === undefined) return
531
+ const def = taskDefinitions([node]).get(node.id)
532
+ const t = timing(outcome)
533
+ if (def !== undefined && t !== undefined) timings.set(def, t)
534
+ })
535
+ // Every outcome is known here, and core's own end of run (history
536
+ // rows, cache uploads, telemetry) is still ahead: sent from teardown,
537
+ // a new OIDC token and the upload shared its 3 s bound.
538
+ ctx.on('onRunEnd', () => {
539
+ if (reported === sent) uploading ??= sendTimings(server, sent)
540
+ })
541
+ } else if (remote !== undefined) {
542
+ if (ctx.ci)
543
+ ctx.warn(
544
+ Bun.env.GITHUB_ACTIONS === 'true'
545
+ ? '[vx] vx-history: this job gets no OIDC token for the history server (give it `permissions: id-token: write`): it reads only'
546
+ : '[vx] vx-history: no CI token for the history server (name an id_tokens entry VX_HISTORY_TOKEN): it reads only',
547
+ )
548
+ } else if ((options.url ?? Bun.env.VX_HISTORY_URL ?? '') !== '') {
549
+ ctx.warn(
550
+ '[vx] vx-history: VX_HISTORY_URL names a server, but vxHistory() names no issuer and repository: the server is not asked',
551
+ )
552
+ }
553
+ if (options.file === undefined) return
554
+ fileWarn = { root: ctx.workspaceRoot, warn: ctx.warn }
555
+ inRun = true
556
+ changed = false
557
+ ctx.on('onTaskComplete', (node, outcome) => {
558
+ // A group runs nothing and is never timed: counting it rewrote the
559
+ // file on every all-cached run that went through one.
560
+ if (node.config.exec?.command === undefined) return
561
+ if (outcome.status === 'success' || !(node.id in (scheduled?.carried ?? {}))) {
562
+ changed = true
563
+ }
564
+ })
565
+ }
566
+ // After the run's history is recorded and before its cache closes.
567
+ hooks.teardown = async () => {
568
+ stopImport?.()
569
+ stopImport = undefined
570
+ const server = remote
571
+ const sent = reported
572
+ const up = uploading
573
+ remote = undefined
574
+ reported = undefined
575
+ uploading = undefined
576
+ // Counted from here: the file's write below shares the bound.
577
+ const waited = server !== undefined && sent !== undefined ? waitFor(UPLOAD_WAIT_MS) : undefined
578
+ const done = scheduled
579
+ const wrote = inRun && changed
580
+ scheduled = undefined
581
+ inRun = false
582
+ // The file first: core bounds a teardown (3 s), and an upload ahead of
583
+ // it held the write hostage to the server's network. A failed write
584
+ // still sends.
585
+ try {
586
+ if (wrote && done !== undefined) {
587
+ const table = await readHistory(done.cache, done.ids, options, done.branch, done.defs)
588
+ const timings = await import('./timings-file.js')
589
+ // Over the file as it is now: a time another writer (a run in another
590
+ // worktree sharing it) put there since `schedule` read it is kept. Its
591
+ // fault, if any, was said when the run read it.
592
+ const now = timings.readTimings(done.file, () => {})
593
+ let written = true
594
+ try {
595
+ timings.writeTimings(done.file, { ...done.carried, ...now }, table)
596
+ } catch (err) {
597
+ // Only the file is lost: said as the read is, not as a plugin failure.
598
+ written = false
599
+ fileWarn?.warn(
600
+ `[vx] vx-history: timings file ${done.file} not written: ${err instanceof Error ? err.message : String(err)}`,
601
+ )
602
+ }
603
+ if (written && fileWarn !== undefined)
604
+ timings.warnUnignored(done.file, fileWarn.root, fileWarn.warn)
605
+ }
606
+ } finally {
607
+ if (server !== undefined && sent !== undefined && waited !== undefined) {
608
+ const u = up ?? sendTimings(server, sent)
609
+ const late = await Promise.race([u.done.then(() => false), waited.done])
610
+ waited.cancel()
611
+ if (late) {
612
+ u.stop.abort()
613
+ const n = sent.timings.size
614
+ sent.warn(
615
+ `[vx] vx-history: the timings of ${n} task${n === 1 ? '' : 's'} were not sent to the history server at ${server.url}: no answer in the ${UPLOAD_WAIT_MS} ms a teardown waits`,
616
+ )
617
+ }
618
+ }
619
+ }
620
+ }
621
+ // What the run's tasks reserve, learned in `schedule` (one history read
622
+ // serves both) and declared in the options; asked at every dispatch.
623
+ let reservations: ReadonlyMap<string, ResourceEstimate> = new Map(
624
+ Object.entries(declaredOf(options) ?? {}),
625
+ )
626
+ if (options.resources !== false || options.reservations !== undefined) {
627
+ // Read on the first admit: every config evaluation runs this factory,
628
+ // and the default budget is cgroup reads on Linux.
629
+ let memoryMb: number | undefined
630
+ hooks.admit = (task, ctx) =>
631
+ admits(
632
+ task.id,
633
+ ctx.running.map((r) => r.id),
634
+ reservations,
635
+ {
636
+ cpus: ctx.concurrency,
637
+ memory: (memoryMb ??= memoryBudgetMb(options)),
638
+ },
639
+ )
640
+ }
641
+ return definePlugin(import.meta, hooks)
642
+ }
643
+
644
+ interface Reported {
645
+ readonly timings: Map<string, ServerTiming>
646
+ readonly warn: (m: string) => void
647
+ }
648
+
649
+ interface Upload {
650
+ /** Settles when sent, or once a failure is said; never rejects. */
651
+ readonly done: Promise<void>
652
+ readonly stop: AbortController
653
+ }
654
+
655
+ /**
656
+ * How long a teardown waits for the upload: under core's bound on one
657
+ * teardown (3 s, `teardownTimeoutMs`), so a dropped upload is said here,
658
+ * with what it dropped, and not as core's line that names no cause.
659
+ */
660
+ const UPLOAD_WAIT_MS = 2_000
661
+
662
+ function sendTimings(server: HistoryServer, sent: Reported): Upload {
663
+ const stop = new AbortController()
664
+ const done = server.upload(sent.timings, stop.signal).catch((err: unknown) => {
665
+ // Abandoned by teardown, which says so itself.
666
+ if (stop.signal.aborted) return
667
+ sent.warn(
668
+ `[vx] vx-history: the timings were not sent to the history server at ${server.url}: ${err instanceof Error ? err.message : String(err)}`,
669
+ )
670
+ })
671
+ return { done, stop }
672
+ }
673
+
674
+ /** Resolves true after `ms`, unless cancelled first. */
675
+ function waitFor(ms: number): { done: Promise<true>; cancel: () => void } {
676
+ let timer: ReturnType<typeof setTimeout> | undefined
677
+ const done = new Promise<true>((resolve) => {
678
+ timer = setTimeout(() => resolve(true), ms)
679
+ })
680
+ return { done, cancel: () => clearTimeout(timer) }
681
+ }
682
+
683
+ /** Default multiplier over the largest peak RSS seen — the asymmetry: over-reserving costs some parallelism, under-reserving meets the OOM killer. */
684
+ const DEFAULT_HEADROOM = 1.25
685
+ const MB = 1024 * 1024
686
+ /** Reservations round up to this so a jittery RSS does not re-key the packing every run. */
687
+ const MEMORY_STEP_MB = 64
688
+
689
+ export interface ResourceEstimate {
690
+ readonly cpus?: number
691
+ readonly memory?: number
692
+ }
693
+
694
+ /**
695
+ * What each task should reserve, from its history: memory is the largest
696
+ * peak RSS in the window times `headroom`, rounded UP to 64 MB and omitted
697
+ * under one step (nothing worth packing). Cores are never learned: the
698
+ * parallelism a build shows is what the machine let it have (1.0× beside
699
+ * three others, 2.0× alone), reserving the solo reading packs a four-core
700
+ * box two wide, and that idles cores through every single-threaded phase —
701
+ * 160 s against 132 s on 92 real builds (2026-09-15). `reservations` may
702
+ * still declare `cpus`. Tasks with no execution in the window are absent.
703
+ */
704
+ export function resourceEstimates(
705
+ nodes: ReadonlyMap<string, TaskNode>,
706
+ history: HistoryTable,
707
+ headroom = DEFAULT_HEADROOM,
708
+ ): ReadonlyMap<string, ResourceEstimate> {
709
+ return estimatesFor(nodes.keys(), history, headroom)
710
+ }
711
+
712
+ function estimatesFor(
713
+ ids: Iterable<string>,
714
+ history: ReadonlyMap<string, TaskResources>,
715
+ headroom: number,
716
+ ): ReadonlyMap<string, ResourceEstimate> {
717
+ const out = new Map<string, ResourceEstimate>()
718
+ for (const id of ids) {
719
+ const rss = history.get(id)?.maxPeakRssBytes
720
+ if (rss === undefined) continue
721
+ const mb = (rss * headroom) / MB
722
+ if (mb >= MEMORY_STEP_MB)
723
+ out.set(id, { memory: Math.ceil(mb / MEMORY_STEP_MB) * MEMORY_STEP_MB })
724
+ }
725
+ return out
726
+ }
727
+
728
+ /**
729
+ * A declared axis wins over the learned one, and an axis the declaration
730
+ * leaves out keeps it: cores are only ever declared, and declaring them
731
+ * dropped the task's learned memory. A task with neither reserves nothing.
732
+ */
733
+ export function withDeclared(
734
+ learned: ReadonlyMap<string, ResourceEstimate>,
735
+ declared: Readonly<Record<string, ResourceEstimate>> | undefined,
736
+ ): ReadonlyMap<string, ResourceEstimate> {
737
+ if (declared === undefined) return learned
738
+ const out = new Map(learned)
739
+ for (const [id, r] of Object.entries(declared)) {
740
+ const merged: { cpus?: number; memory?: number } = { ...learned.get(id) }
741
+ if (r.cpus !== undefined) merged.cpus = r.cpus
742
+ if (r.memory !== undefined) merged.memory = r.memory
743
+ if (merged.cpus !== undefined || merged.memory !== undefined) out.set(id, merged)
744
+ }
745
+ return out
746
+ }
747
+
748
+ export interface Budgets {
749
+ /** Cores: the run's worker count, which the count gate already applies. */
750
+ readonly cpus: number
751
+ /** Megabytes. */
752
+ readonly memory: number
753
+ }
754
+
755
+ /**
756
+ * The packing rule, per axis: a task reserving nothing is admitted; one
757
+ * within the budget needs the headroom left by what runs; one over the
758
+ * whole budget can never have it, so it runs alone — admitted only when
759
+ * nothing else runs, which an idle machine always reaches, so it never
760
+ * starves. Whole megabytes and cores, so sums are exact.
761
+ */
762
+ export function admits(
763
+ id: string,
764
+ running: readonly string[],
765
+ reservations: ReadonlyMap<string, ResourceEstimate>,
766
+ budgets: Budgets,
767
+ ): boolean {
768
+ const mine = reservations.get(id)
769
+ if (mine === undefined) return true
770
+ let cpus = 0
771
+ let memory = 0
772
+ for (const r of running) {
773
+ const theirs = reservations.get(r)
774
+ if (theirs === undefined) continue
775
+ cpus += theirs.cpus ?? 0
776
+ memory += theirs.memory ?? 0
777
+ }
778
+ return (
779
+ fitsAxis(mine.cpus ?? 0, cpus, running.length, budgets.cpus) &&
780
+ fitsAxis(mine.memory ?? 0, memory, running.length, budgets.memory)
781
+ )
782
+ }
783
+
784
+ function fitsAxis(cost: number, reserved: number, holders: number, budget: number): boolean {
785
+ if (cost === 0) return true
786
+ return cost <= budget ? reserved + cost <= budget : holders === 0
787
+ }
788
+ // `vx history` — the plugin's own surface for what it learned. The
789
+ // reservations are decided at dispatch and shown nowhere by core (core
790
+ // holds no notion of them), so without this a developer could not tell
791
+ // what the plugin will pack, or why two tasks stopped overlapping.
792
+ async function historyCmd(
793
+ argv: readonly string[],
794
+ ctx: CommandContext,
795
+ options: VxHistoryOptions,
796
+ ): Promise<number> {
797
+ let format: 'pretty' | 'json' = 'pretty'
798
+ for (let i = 0; i < argv.length; i++) {
799
+ const a = argv[i]!
800
+ // A plugin verb owns its help; completions offer `--help` for every verb.
801
+ if (a === '--help' || a === '-h') {
802
+ ui.say('info', 'Usage: vx history [--format pretty|json]', 'out')
803
+ process.stdout.write('\n')
804
+ ui.say(
805
+ 'info',
806
+ 'What vx-history learned per task (p50, peak RSS, CPU parallelism) and the reservation it packs.',
807
+ 'out',
808
+ )
809
+ return 0
810
+ }
811
+ // Worded as core's verbs word theirs, help pointer included.
812
+ const see = ' (see `vx history --help`)'
813
+ if (a === '--format' || a.startsWith('--format=')) {
814
+ const v = a === '--format' ? argv[++i] : a.slice(9)
815
+ if (v === undefined || v === '')
816
+ throw new UserError(
817
+ `vx history: --format requires a value: pretty or json${see}`,
818
+ 'VX_E_USAGE',
819
+ )
820
+ if (v !== 'pretty' && v !== 'json')
821
+ throw new UserError(
822
+ `vx history: --format must be pretty or json (got ${v})${see}`,
823
+ 'VX_E_USAGE',
824
+ )
825
+ format = v
826
+ continue
827
+ }
828
+ throw new UserError(
829
+ `vx history: ${a.startsWith('-') ? 'unknown flag' : 'unexpected argument'}: ${a}${see}`,
830
+ 'VX_E_USAGE',
831
+ )
832
+ }
833
+ // The tasks a run would see, plugin stages included — the same ids the
834
+ // `schedule` hook is handed.
835
+ const projects = await loadResolvedProjects(ctx.workspaceRoot, { scope: 'all', warn: ctx.warn })
836
+ const ids: string[] = []
837
+ const tasks: Pick<TaskNode, 'id' | 'config'>[] = []
838
+ for (const p of projects.values()) {
839
+ for (const [t, config] of Object.entries(p.config.tasks ?? {})) {
840
+ ids.push(`${p.name}#${t}`)
841
+ tasks.push({ id: `${p.name}#${t}`, config })
842
+ }
843
+ }
844
+ numberWarnings(options, ctx.warn)
845
+ const window = windowOf(options)
846
+ const cache = new Cache(ctx.cacheDir)
847
+ let table: HistoryTable
848
+ try {
849
+ // Each task's rows of its definition as it is now, as `schedule` reads
850
+ // them: read with every definition's, a task whose command changed
851
+ // showed the old command's peak and a reservation no run packs.
852
+ table = await new LocalHistoryProvider(
853
+ cache.dbHandle(),
854
+ window,
855
+ currentBranch(ctx.workspaceRoot),
856
+ // As a run reads them: a task whose command changed has no rows yet.
857
+ taskDefinitions(tasks as TaskNode[]),
858
+ ).loadFor(ids)
859
+ } finally {
860
+ cache.close()
861
+ }
862
+ const reservations = reservationsFor(ids, table, options)
863
+ const budgets: Budgets = {
864
+ cpus: ctx.concurrency,
865
+ memory: memoryBudgetMb(options),
866
+ }
867
+ const rows: HistoryRow[] = ids.map((id) => {
868
+ const h = table.get(id)
869
+ return {
870
+ id,
871
+ runs: h?.runs ?? 0,
872
+ p50DurationMs: h?.p50DurationMs ?? null,
873
+ maxPeakRssBytes: h?.maxPeakRssBytes ?? null,
874
+ maxCpuParallelism: h?.maxCpuParallelism ?? null,
875
+ reservation: reservations.get(id) ?? null,
876
+ declared: options.reservations?.[id] !== undefined,
877
+ }
878
+ })
879
+ if (format === 'json') {
880
+ process.stdout.write(`${JSON.stringify({ window, budgets, tasks: rows })}\n`)
881
+ return 0
882
+ }
883
+ // The table's module loads with the verb, off every run's path.
884
+ const { historyBlock } = await import('./history-view.js')
885
+ ui.print('out', historyBlock(rows, window, budgets, usable(options.memory)))
886
+ return 0
887
+ }