@vzn/vx-dte 0.0.652 → 0.0.653

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/plugin.ts CHANGED
@@ -22,7 +22,13 @@ import type { Orchestrator } from './orchestrator.js'
22
22
  import type { Proof } from './seal.js'
23
23
  import type { Forecast, Size } from './planner.js'
24
24
 
25
- /** One kind of worker the adapter can start. */
25
+ /** One worker type: the machines of it the adapter can start, by name. */
26
+ export interface WorkerType {
27
+ /** Its machines by name: each a size the adapter may start, named `<type>.<machine>`. */
28
+ readonly machines: Readonly<Record<string, WorkerSize>>
29
+ }
30
+
31
+ /** One kind of machine the adapter can start. */
26
32
  export interface WorkerSize {
27
33
  /** How many of it one run may start; absent, as many as the work needs. */
28
34
  readonly max?: number
@@ -32,6 +38,8 @@ export interface WorkerSize {
32
38
  readonly memory: number | string
33
39
  /** Its machines' platform, which a joining worker must report; absent, planned as this machine's and not checked. A task naming `exec.platforms` runs only on a worker of one of them. */
34
40
  readonly platform?: Platform
41
+ /** The CI runner label its workers run on, which `vx dte init` writes (`github()`); absent, its platform's GitHub-hosted runner. */
42
+ readonly runsOn?: string
35
43
  }
36
44
 
37
45
  /** What an adapter is asked to start: one worker of `size`. */
@@ -103,8 +111,13 @@ export interface DteAdapter {
103
111
  }
104
112
 
105
113
  export interface DteOptions {
106
- /** What the adapter may start, by size name. */
107
- readonly workers?: Readonly<Record<string, WorkerSize>>
114
+ /**
115
+ * The worker types, in order of preference: a task naming `exec.worker`
116
+ * runs only on that type's machines, any other on the first type with
117
+ * a machine of one of its platforms. Each machine is a size the adapter
118
+ * may start, named `<type>.<machine>` (`ubuntu.small`).
119
+ */
120
+ readonly workers?: Readonly<Record<string, WorkerType>>
108
121
  /** Starts workers; absent, the run uses the workers that join it, which needs `relay`. */
109
122
  readonly adapter?: DteAdapter
110
123
  /** The relay's URL; absent, the adapter's own. */
@@ -139,12 +152,14 @@ const KEYS: PluginOptionKinds<DteOptions> = {
139
152
  headers: 'object',
140
153
  }
141
154
 
155
+ const TYPE_KEYS: PluginOptionKinds<WorkerType> = { machines: 'object' }
142
156
  // A typo in a size or in `unmeasured` (`maxx`) was dropped, and the size ran uncapped.
143
157
  const SIZE_KEYS: PluginOptionKinds<WorkerSize> = {
144
158
  max: 'number',
145
159
  cpu: 'number',
146
160
  memory: 'any',
147
161
  platform: 'string',
162
+ runsOn: 'string',
148
163
  }
149
164
  const UNMEASURED_KEYS: PluginOptionKinds<NonNullable<DteOptions['unmeasured']>> = {
150
165
  cpu: 'number',
@@ -189,8 +204,19 @@ export function dte(options: DteOptions = {}) {
189
204
  refuse(`scheduler.${layer}`, `one of ${choices.join(', ')}`, choice)
190
205
  ;(how as unknown as Record<string, unknown>)[layer] = choice
191
206
  }
192
- const sizes = Object.entries(options.workers ?? {}).map(([name, s]) => {
193
- const at = `workers.${name}`
207
+ const declared = Object.entries(options.workers ?? {}).flatMap(([type, t]) => {
208
+ refuseUnknownOptions(`dte() option "workers.${type}"`, t, TYPE_KEYS)
209
+ const machines = Object.entries(t?.machines ?? {})
210
+ if (machines.length === 0)
211
+ refuse(
212
+ `workers.${type}.machines`,
213
+ "machines by name, such as small: { cpu: 4, memory: '8GB' }",
214
+ t?.machines,
215
+ )
216
+ return machines.map(([machine, s]) => ({ type, name: `${type}.${machine}`, s }))
217
+ })
218
+ const sized = declared.map(({ type, name, s }) => {
219
+ const at = `workers.${type}.machines.${name.slice(type.length + 1)}`
194
220
  refuseUnknownOptions(`dte() option "${at}"`, s, SIZE_KEYS)
195
221
  if (!(Number.isInteger(s?.cpu) && s.cpu >= 1)) refuse(`${at}.cpu`, 'a whole number ≥ 1', s?.cpu)
196
222
  const memory = bytes(s.memory)
@@ -198,23 +224,62 @@ export function dte(options: DteOptions = {}) {
198
224
  refuse(`${at}.memory`, "bytes above 0 or a size such as '8GB'", s.memory)
199
225
  if (!(s.max === undefined || (Number.isInteger(s.max) && s.max >= 0)))
200
226
  refuse(`${at}.max`, 'a whole number ≥ 0', s.max)
227
+ if (s.runsOn !== undefined && s.runsOn.trim() === '')
228
+ refuse(`${at}.runsOn`, 'a runner label', s.runsOn)
201
229
  if (s.platform !== undefined && !PLATFORMS.includes(s.platform))
202
230
  refuse(`${at}.platform`, `one of ${PLATFORMS.join(', ')}`, s.platform)
203
231
  return {
232
+ type,
204
233
  name,
205
234
  max: s.max ?? Infinity,
206
235
  cpu: s.cpu,
207
236
  memory: memory!,
208
237
  platform: s.platform ?? hostPlatform(),
238
+ named: s.platform,
239
+ runsOn: s.runsOn,
209
240
  }
210
241
  })
242
+ const types = [...new Set(sized.map((s) => s.type))]
243
+ const typeOf = new Map(sized.map((s) => [s.name, s.type]))
244
+ // With one type every worker is of it; with more, a worker's type rides
245
+ // its platform as `<type>:<platform>`, which every match compares whole.
246
+ const multi = types.length > 1
247
+ const sizes: Size[] = sized.map((s) => ({
248
+ name: s.name,
249
+ max: s.max,
250
+ cpu: s.cpu,
251
+ memory: s.memory,
252
+ platform: multi ? `${s.type}:${s.platform}` : s.platform,
253
+ }))
254
+ const host = hostPlatform()
255
+ /** The platform a task of `type` naming none runs on: this machine's if the type has it, else its first. */
256
+ const homeOfType = (type: string): string => {
257
+ const of = sized.filter((s) => s.type === type).map((s) => s.platform)
258
+ return of.includes(host) ? host : of[0]!
259
+ }
260
+ /** Where a task runs, as workers' platforms read: its type's place, or as one type's run reads it. */
261
+ const placeOf = (worker: string | undefined, platform: string | undefined): string | undefined =>
262
+ !multi || worker === undefined
263
+ ? (platform ?? home)
264
+ : `${worker}:${platform ?? homeOfType(worker)}`
265
+ /** A worker's place: its size's type, else (one that joined on its own) the first type of its platform. */
266
+ const joinedPlace = (
267
+ size: string | undefined,
268
+ platform: string | undefined,
269
+ ): string | undefined => {
270
+ if (!multi || platform === undefined) return platform
271
+ const type =
272
+ (size === undefined ? undefined : typeOf.get(size)) ??
273
+ sized.find((s) => s.platform === platform)?.type
274
+ return type === undefined ? platform : `${type}:${platform}`
275
+ }
211
276
  // Only a platform the size names refuses a worker reporting another: an
212
277
  // unnamed one is planned as this machine's, and takes whatever joins.
213
278
  const named = (size: string): { platform?: Platform } => {
214
- const platform = options.workers?.[size]?.platform
279
+ const platform = sized.find((s) => s.name === size)?.named
215
280
  return platform === undefined ? {} : { platform }
216
281
  }
217
- const home = homeOf(sizes, hostPlatform())
282
+ const home = multi ? undefined : homeOf(sizes, host)
218
283
  const unmeasured = unmeasuredOf(options.unmeasured)
219
284
  const adapter = options.adapter
220
285
  // An adapter's run in its own CI with a relay names itself: the CI file
@@ -234,7 +299,7 @@ export function dte(options: DteOptions = {}) {
234
299
  // With none, no worker was ever asked for and the run waited until killed.
235
300
  if (adapter !== undefined && !sizes.some((s) => s.max > 0)) {
236
301
  throw new UserError(
237
- "dte(): workers names no size the adapter may start (one with max above 0), such as small: { cpu: 2, memory: '8GB' }",
302
+ "dte(): workers names no machine the adapter may start (one with max above 0), such as linux: { machines: { small: { cpu: 2, memory: '8GB' } } }",
238
303
  )
239
304
  }
240
305
  if (adapter === undefined && options.relay === undefined) {
@@ -267,6 +332,10 @@ export function dte(options: DteOptions = {}) {
267
332
  let peaks: ReadonlyMap<string, TaskResources> = new Map()
268
333
  /** Each size's warm-up an earlier run learned (`warm-ups.ts`), and the read of it. */
269
334
  let learned: ReadonlyMap<string, number> = new Map()
335
+ /** Each server's base an earlier run learned, then this run's workers' over it (`server-fit.ts`). */
336
+ let bases = new Map<string, number>()
337
+ /** The bases as read, before this run's: kept with this run's over them. */
338
+ let basesRead: ReadonlyMap<string, number> = new Map()
270
339
  let warming: Promise<void> = Promise.resolve()
271
340
  /** On CI, where the run keeps the warm-ups it learned: `setup`'s cache, root and warn. */
272
341
  let keep: { cache: PluginCache; root: string; warn: (m: string) => void } | undefined
@@ -281,6 +350,11 @@ export function dte(options: DteOptions = {}) {
281
350
  // Each finished task's cache key, a hit's too: what a worker restores a
282
351
  // dependency by.
283
352
  const keys = new Map<string, string>()
353
+ // Tasks that run a command and have not ended, by the platform they name
354
+ // ('' for none): a worker the run cannot start again stays while its
355
+ // platform has one (`keepLast`).
356
+ const leftOf = new Map<string, number>()
357
+ const ended = new Set<string>()
284
358
  let orchestrator: Orchestrator | undefined
285
359
  let closing: Promise<void> | undefined
286
360
 
@@ -298,7 +372,18 @@ export function dte(options: DteOptions = {}) {
298
372
  k.warn(`[vx] dte: warm-ups not kept: ${err instanceof Error ? err.message : String(err)}`),
299
373
  )
300
374
  }
375
+ if (k !== undefined && o !== undefined) {
376
+ const fitted = new Map([...bases].filter(([id, c]) => basesRead.get(id) !== c))
377
+ const { writeServerBases } = await import('./warm-ups.js')
378
+ await writeServerBases(k.cache, k.root, basesRead, fitted).catch((err: unknown) =>
379
+ k.warn(
380
+ `[vx] dte: server bases not kept: ${err instanceof Error ? err.message : String(err)}`,
381
+ ),
382
+ )
383
+ }
301
384
  learned = new Map()
385
+ bases = new Map()
386
+ basesRead = new Map()
302
387
  warming = Promise.resolve()
303
388
  peaks = new Map()
304
389
  tails = undefined
@@ -343,10 +428,11 @@ export function dte(options: DteOptions = {}) {
343
428
  workspaceRoot: root,
344
429
  dir: git('--show-prefix').replace(/\/$/, ''),
345
430
  ...(options.relay !== undefined ? { relay: options.relay } : {}),
346
- sizes: sizes.map((s) => ({
431
+ sizes: sized.map((s) => ({
347
432
  name: s.name,
348
433
  ...(s.max === Infinity ? {} : { max: s.max }),
349
- ...named(s.name),
434
+ ...(s.named === undefined ? {} : { platform: s.named }),
435
+ ...(s.runsOn === undefined ? {} : { runsOn: s.runsOn }),
350
436
  })),
351
437
  manager: managerOf(root),
352
438
  tasks: askTasks(),
@@ -432,6 +518,10 @@ export function dte(options: DteOptions = {}) {
432
518
  if (ctx.ci) keep = { cache: ctx.cache, root: ctx.workspaceRoot, warn: (m) => ctx.warn(m) }
433
519
  ctx.on('onTaskComplete', (node, outcome) => {
434
520
  if (outcome.hash !== undefined) keys.set(node.id, outcome.hash)
521
+ if (node.config.exec?.command === undefined || ended.has(node.id)) return
522
+ ended.add(node.id)
523
+ const p = placeOf(node.worker, node.platform) ?? ''
524
+ leftOf.set(p, (leftOf.get(p) ?? 1) - 1)
435
525
  })
436
526
  },
437
527
 
@@ -441,9 +531,32 @@ export function dte(options: DteOptions = {}) {
441
531
  tails = undefined
442
532
  // A run that is not distributed reads nothing.
443
533
  if (orchestrator === undefined) return undefined
534
+ // An uncached upstream runs again where its dependant runs, on a
535
+ // worker type or platform it may name otherwise: refused here, not
536
+ // run there (2026-11-08).
537
+ const { apart } = await import('./orchestrator.js')
538
+ const where = (n: TaskNode): string => placeOf(n.worker, n.platform) ?? ''
539
+ const platformOf = (n: TaskNode): string => where(n).slice(where(n).indexOf(':') + 1)
540
+ const bound = apart(
541
+ graph,
542
+ (u, d) =>
543
+ (multi && u.config.exec?.worker !== undefined && u.worker !== d.worker) ||
544
+ (u.config.exec?.platforms !== undefined && platformOf(u) !== platformOf(d)),
545
+ )
546
+ if (bound !== undefined)
547
+ throw new UserError(
548
+ `dte(): ${bound.dependant} runs on ${where(graph.get(bound.dependant)!)} and reads what uncached ${bound.upstream} leaves where it runs, which cannot be there: its exec.worker or exec.platforms say ${where(graph.get(bound.upstream)!)}. Cache ${bound.upstream}, or run both alike`,
549
+ )
444
550
  const ids = [...graph.values()]
445
551
  .filter((n) => n.config.exec?.command !== undefined)
446
552
  .map((n) => n.id)
553
+ leftOf.clear()
554
+ ended.clear()
555
+ for (const id of ids) {
556
+ const n = graph.get(id)!
557
+ const p = placeOf(n.worker, n.platform) ?? ''
558
+ leftOf.set(p, (leftOf.get(p) ?? 0) + 1)
559
+ }
447
560
  const read = async (): Promise<void> => {
448
561
  if (orchestrator === undefined) return
449
562
  try {
@@ -452,6 +565,7 @@ export function dte(options: DteOptions = {}) {
452
565
  defs: taskDefinitions(graph.values()),
453
566
  }).resourcesFor(ids)
454
567
  tails = undefined
568
+ orchestrator?.rerank()
455
569
  } catch (err) {
456
570
  ctx.warn(
457
571
  `[vx] dte: tasks are packed one core each: ${err instanceof Error ? err.message : String(err)}`,
@@ -459,6 +573,18 @@ export function dte(options: DteOptions = {}) {
459
573
  }
460
574
  }
461
575
  let reading = read()
576
+ const basesIn = import('./warm-ups.js')
577
+ .then((w) => w.readServerBases(ctx.cache, ctx.workspaceRoot))
578
+ .then(
579
+ (m) => {
580
+ basesRead = m
581
+ for (const [id, cores] of m) if (!bases.has(id)) bases.set(id, cores)
582
+ },
583
+ (err: unknown) =>
584
+ ctx.warn(
585
+ `[vx] dte: no server bases learned: ${err instanceof Error ? err.message : String(err)}`,
586
+ ),
587
+ )
462
588
  warming = import('./warm-ups.js')
463
589
  .then((w) => w.readWarmUps(ctx.cache, ctx.workspaceRoot))
464
590
  .then(
@@ -472,7 +598,7 @@ export function dte(options: DteOptions = {}) {
472
598
  const { likelyMisses } = await import('./forecast.js')
473
599
  likely = likelyMisses(graph, changed, ctx.workspaceRoot)
474
600
  })
475
- await Promise.all([reading, guess])
601
+ await Promise.all([reading, guess, basesIn])
476
602
  // Needs another plugin fetched (the default branch's timings) land
477
603
  // in history after this: the tasks not yet placed pack by them, and
478
604
  // the first ask for workers waits for them.
@@ -497,8 +623,8 @@ export function dte(options: DteOptions = {}) {
497
623
  const [
498
624
  { Orchestrator },
499
625
  { joinOffLoop },
500
- { decide, knownMisses, slowdown, tailsMs, WARM_UP_MS },
501
- { snapshot },
626
+ { decide, keepLast, knownMisses, slowdown, tailsMs, WARM_UP_MS },
627
+ { snapshot, untrackedWorkspace },
502
628
  seal,
503
629
  ] = await Promise.all([
504
630
  import('./orchestrator.js'),
@@ -527,7 +653,23 @@ export function dte(options: DteOptions = {}) {
527
653
  proof ??= seal.secretProof(secret!)
528
654
  const { commit, tree } = await snapshot(ctx.workspaceRoot)
529
655
  if (tree === '') throw new UserError('dte(): a DTE run needs a git commit')
530
- const need = (id: string): Need => booked(needOf(peaks.get(id), unmeasured), how.book)
656
+ const untracked = await untrackedWorkspace(ctx.workspaceRoot, tree)
657
+ if (untracked !== undefined)
658
+ throw new UserError(
659
+ `dte(): ${untracked} is untracked, so the commit workers check out lacks it: commit it`,
660
+ )
661
+ const serves = new Map<string, readonly string[]>()
662
+ const need = (id: string): Need => {
663
+ const n = booked(needOf(peaks.get(id), unmeasured), how.book)
664
+ let ids = serves.get(id)
665
+ if (ids === undefined) {
666
+ ids = serversOf(nodes, id)
667
+ // Read again once the graph is: none before it.
668
+ if (nodes !== undefined) serves.set(id, ids)
669
+ }
670
+ if (ids.length === 0) return n
671
+ return { ...n, servers: ids.map((sv) => ({ id: sv, cores: bases.get(sv) ?? 0 })) }
672
+ }
531
673
  // The run plans again on it, now when `now`; set once the plan is.
532
674
  let onChange = (_now?: boolean): void => {}
533
675
  const o = new Orchestrator({
@@ -543,6 +685,7 @@ export function dte(options: DteOptions = {}) {
543
685
  tree,
544
686
  nodes: () => nodes,
545
687
  need,
688
+ bases,
546
689
  artifacts: ctx.artifacts,
547
690
  cacheFacts: () => ctx.cacheFacts(),
548
691
  run,
@@ -552,6 +695,7 @@ export function dte(options: DteOptions = {}) {
552
695
  (tails ??= tailsMs(nodes ?? new Map(), (x) => peaks.get(x)?.p50DurationMs))(id),
553
696
  how,
554
697
  home,
698
+ joinedPlace,
555
699
  lost: (id) => {
556
700
  const s = sizeOf.get(id)
557
701
  if (s === undefined) return
@@ -592,6 +736,7 @@ export function dte(options: DteOptions = {}) {
592
736
  cpu: s.cpu,
593
737
  memory: s.memory,
594
738
  ...named(s.name),
739
+ ...(multi && s.platform !== undefined ? { place: s.platform } : {}),
595
740
  })
596
741
  sizeOf.set(id, s)
597
742
  return {
@@ -657,7 +802,14 @@ export function dte(options: DteOptions = {}) {
657
802
  const m = p50(id)
658
803
  return m === undefined ? undefined : m * slow
659
804
  }
660
- const { workers, running } = o.planState(ms, warmUp)
805
+ const state = o.planState(ms, warmUp)
806
+ const running = state.running
807
+ const workers = keepLast(
808
+ state.workers,
809
+ sizes,
810
+ started,
811
+ (p) => (leftOf.get(p) ?? 0) > 0 || (p === home && (leftOf.get('') ?? 0) > 0),
812
+ )
661
813
  const final = adapter === undefined
662
814
  const p = decide({
663
815
  remaining,
@@ -676,7 +828,7 @@ export function dte(options: DteOptions = {}) {
676
828
  limitMs: deadline === undefined ? undefined : deadline - performance.now(),
677
829
  known,
678
830
  how,
679
- home,
831
+ home: multi ? (n: TaskNode) => placeOf(n.worker, n.platform) : home,
680
832
  queued: o.queued(warmUp),
681
833
  last,
682
834
  now: performance.now(),
@@ -815,23 +967,30 @@ export function dte(options: DteOptions = {}) {
815
967
  // A worker answers a task's runtime probes before it runs it.
816
968
  runtime: true,
817
969
  // Its sizes' platforms; workers that join on their own, this machine's.
818
- platforms:
819
- sizes.length === 0 ? [hostPlatform()] : [...new Set(sizes.map((s) => s.platform))],
820
- // Room for the workers' cores and their prefetch; the orchestrator
821
- // queues the rest. Workers that join on their own are as many as join.
822
- capacity:
823
- adapter === undefined || sizes.some((s) => s.max === Infinity)
824
- ? JOINED_CAPACITY
825
- : Math.max(
826
- 1,
827
- sizes.reduce((n, s) => n + 2 * s.max * s.cpu, 0),
970
+ platforms: sized.length === 0 ? [host] : [...new Set(sized.map((s) => s.platform))],
971
+ // Its worker types, in declared order, each with its machines' platforms.
972
+ ...(types.length === 0
973
+ ? {}
974
+ : {
975
+ workers: types.map(
976
+ (t) =>
977
+ [
978
+ t,
979
+ [...new Set(sized.filter((s) => s.type === t).map((s) => s.platform))],
980
+ ] as const,
828
981
  ),
982
+ }),
983
+ // Every ready task: the orchestrator's queue knows which platform's
984
+ // worker is free, core's order does not. A pool of twice the cores,
985
+ // shared by all platforms, held a 2 s arm task 79 s behind x64 work
986
+ // with its worker idle (main a1950a2, 2026-11-07).
987
+ capacity: Number.MAX_SAFE_INTEGER,
829
988
  execute: (req) => {
830
989
  // A miss: the run's known work grows.
831
990
  missed = true
832
991
  executed.add(req.taskId)
833
992
  replan()
834
- return o.execute(req)
993
+ return o.execute(multi ? { ...req, platform: placeOf(req.worker, req.platform)! } : req)
835
994
  },
836
995
  footer: () =>
837
996
  o.footer(
@@ -843,6 +1002,7 @@ export function dte(options: DteOptions = {}) {
843
1002
  advice: () => o.advice(depsOf, sizes),
844
1003
  profile: () => o.profile(),
845
1004
  running: (id) => o.where(id),
1005
+ unneeded: (id) => o.unneeded(id),
846
1006
  demand(remaining, misses) {
847
1007
  if (pending === undefined) o.probed(remaining.size, misses.size)
848
1008
  o.missed = misses
@@ -948,6 +1108,28 @@ function refuse(option: string, want: string, got: unknown): never {
948
1108
  throw new UserError(`dte() option "${option}" must be ${want}, got ${said}`)
949
1109
  }
950
1110
 
1111
+ /**
1112
+ * The persistent tasks `id` needs where it runs, as core hands them to a
1113
+ * `servers` executor: its dependencies that are servers, through groups,
1114
+ * and the servers they depend on.
1115
+ */
1116
+ export function serversOf(nodes: ReadonlyMap<string, TaskNode> | undefined, id: string): string[] {
1117
+ const out: string[] = []
1118
+ const seen = new Set<string>()
1119
+ const stack = [...(nodes?.get(id)?.deps ?? [])]
1120
+ while (stack.length > 0) {
1121
+ const d = stack.pop()!
1122
+ if (seen.has(d)) continue
1123
+ seen.add(d)
1124
+ const n = nodes?.get(d)
1125
+ if (n === undefined) continue
1126
+ if (n.config.exec?.persistent !== undefined) out.push(d)
1127
+ else if (n.config.exec !== undefined) continue
1128
+ stack.push(...n.deps)
1129
+ }
1130
+ return out
1131
+ }
1132
+
951
1133
  /** What a task asks of a worker, from its history. */
952
1134
  export function needOf(r: TaskResources | undefined, ask: Need = FRESH): Need {
953
1135
  // Unmeasured (new, or over before the Linux sandbox's first look, which
@@ -969,9 +1151,6 @@ export function needOf(r: TaskResources | undefined, ask: Need = FRESH): Need {
969
1151
  return { cores: Math.max(0.25, r.maxCpuParallelism), memory: r.maxPeakRssBytes ?? 0 }
970
1152
  }
971
1153
 
972
- /** The executor's pool when workers are uncapped: more than any run starts at once. */
973
- const JOINED_CAPACITY = 1024
974
-
975
1154
  const UNITS: Record<string, number> = {
976
1155
  B: 1,
977
1156
  KB: 1e3,