@vzn/vx-dte 0.0.651 → 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
@@ -20,9 +20,15 @@ import type { Need } from './dispatch.js'
20
20
  import { booked, DEFAULT_STRATEGY, STRATEGIES, type Strategy } from './strategy.js'
21
21
  import type { Orchestrator } from './orchestrator.js'
22
22
  import type { Proof } from './seal.js'
23
- import type { Size } from './planner.js'
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) {
@@ -246,6 +311,12 @@ export function dte(options: DteOptions = {}) {
246
311
  `dte() option "relay" must be an http(s) URL, got ${JSON.stringify(shown(options.relay))}`,
247
312
  )
248
313
  }
314
+ const unrouted = options.relay === undefined ? undefined : unroutable(options.relay)
315
+ if (unrouted !== undefined) {
316
+ throw new UserError(
317
+ `dte() option "relay" ${unrouted}, got ${JSON.stringify(shown(options.relay!))}`,
318
+ )
319
+ }
249
320
  // On GitHub Actions with none given, one for the relay's URL.
250
321
  const token = async (relay: string): Promise<string | undefined> =>
251
322
  (await options.token?.()) ??
@@ -261,6 +332,10 @@ export function dte(options: DteOptions = {}) {
261
332
  let peaks: ReadonlyMap<string, TaskResources> = new Map()
262
333
  /** Each size's warm-up an earlier run learned (`warm-ups.ts`), and the read of it. */
263
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()
264
339
  let warming: Promise<void> = Promise.resolve()
265
340
  /** On CI, where the run keeps the warm-ups it learned: `setup`'s cache, root and warn. */
266
341
  let keep: { cache: PluginCache; root: string; warn: (m: string) => void } | undefined
@@ -275,6 +350,11 @@ export function dte(options: DteOptions = {}) {
275
350
  // Each finished task's cache key, a hit's too: what a worker restores a
276
351
  // dependency by.
277
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>()
278
358
  let orchestrator: Orchestrator | undefined
279
359
  let closing: Promise<void> | undefined
280
360
 
@@ -292,7 +372,18 @@ export function dte(options: DteOptions = {}) {
292
372
  k.warn(`[vx] dte: warm-ups not kept: ${err instanceof Error ? err.message : String(err)}`),
293
373
  )
294
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
+ }
295
384
  learned = new Map()
385
+ bases = new Map()
386
+ basesRead = new Map()
296
387
  warming = Promise.resolve()
297
388
  peaks = new Map()
298
389
  tails = undefined
@@ -337,10 +428,11 @@ export function dte(options: DteOptions = {}) {
337
428
  workspaceRoot: root,
338
429
  dir: git('--show-prefix').replace(/\/$/, ''),
339
430
  ...(options.relay !== undefined ? { relay: options.relay } : {}),
340
- sizes: sizes.map((s) => ({
431
+ sizes: sized.map((s) => ({
341
432
  name: s.name,
342
433
  ...(s.max === Infinity ? {} : { max: s.max }),
343
- ...named(s.name),
434
+ ...(s.named === undefined ? {} : { platform: s.named }),
435
+ ...(s.runsOn === undefined ? {} : { runsOn: s.runsOn }),
344
436
  })),
345
437
  manager: managerOf(root),
346
438
  tasks: askTasks(),
@@ -388,6 +480,9 @@ export function dte(options: DteOptions = {}) {
388
480
  }
389
481
  if (!/^https?:\/\//.test(relay))
390
482
  throw new UserError(`vx dte worker: VX_DTE_RELAY must be http(s), got ${shown(relay)}`)
483
+ const unroutedEnv = unroutable(relay)
484
+ if (unroutedEnv !== undefined)
485
+ throw new UserError(`vx dte worker: VX_DTE_RELAY ${unroutedEnv}, got ${shown(relay)}`)
391
486
  const secret = givenSecret()
392
487
  if (secret === undefined && !onGitHub()) {
393
488
  throw new UserError(
@@ -423,6 +518,10 @@ export function dte(options: DteOptions = {}) {
423
518
  if (ctx.ci) keep = { cache: ctx.cache, root: ctx.workspaceRoot, warn: (m) => ctx.warn(m) }
424
519
  ctx.on('onTaskComplete', (node, outcome) => {
425
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)
426
525
  })
427
526
  },
428
527
 
@@ -432,9 +531,32 @@ export function dte(options: DteOptions = {}) {
432
531
  tails = undefined
433
532
  // A run that is not distributed reads nothing.
434
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
+ )
435
550
  const ids = [...graph.values()]
436
551
  .filter((n) => n.config.exec?.command !== undefined)
437
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
+ }
438
560
  const read = async (): Promise<void> => {
439
561
  if (orchestrator === undefined) return
440
562
  try {
@@ -443,6 +565,7 @@ export function dte(options: DteOptions = {}) {
443
565
  defs: taskDefinitions(graph.values()),
444
566
  }).resourcesFor(ids)
445
567
  tails = undefined
568
+ orchestrator?.rerank()
446
569
  } catch (err) {
447
570
  ctx.warn(
448
571
  `[vx] dte: tasks are packed one core each: ${err instanceof Error ? err.message : String(err)}`,
@@ -450,6 +573,18 @@ export function dte(options: DteOptions = {}) {
450
573
  }
451
574
  }
452
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
+ )
453
588
  warming = import('./warm-ups.js')
454
589
  .then((w) => w.readWarmUps(ctx.cache, ctx.workspaceRoot))
455
590
  .then(
@@ -463,7 +598,7 @@ export function dte(options: DteOptions = {}) {
463
598
  const { likelyMisses } = await import('./forecast.js')
464
599
  likely = likelyMisses(graph, changed, ctx.workspaceRoot)
465
600
  })
466
- await Promise.all([reading, guess])
601
+ await Promise.all([reading, guess, basesIn])
467
602
  // Needs another plugin fetched (the default branch's timings) land
468
603
  // in history after this: the tasks not yet placed pack by them, and
469
604
  // the first ask for workers waits for them.
@@ -488,8 +623,8 @@ export function dte(options: DteOptions = {}) {
488
623
  const [
489
624
  { Orchestrator },
490
625
  { joinOffLoop },
491
- { decide, knownMisses, slowdown, tailsMs, WARM_UP_MS },
492
- { snapshot },
626
+ { decide, keepLast, knownMisses, slowdown, tailsMs, WARM_UP_MS },
627
+ { snapshot, untrackedWorkspace },
493
628
  seal,
494
629
  ] = await Promise.all([
495
630
  import('./orchestrator.js'),
@@ -518,7 +653,23 @@ export function dte(options: DteOptions = {}) {
518
653
  proof ??= seal.secretProof(secret!)
519
654
  const { commit, tree } = await snapshot(ctx.workspaceRoot)
520
655
  if (tree === '') throw new UserError('dte(): a DTE run needs a git commit')
521
- 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
+ }
522
673
  // The run plans again on it, now when `now`; set once the plan is.
523
674
  let onChange = (_now?: boolean): void => {}
524
675
  const o = new Orchestrator({
@@ -534,6 +685,7 @@ export function dte(options: DteOptions = {}) {
534
685
  tree,
535
686
  nodes: () => nodes,
536
687
  need,
688
+ bases,
537
689
  artifacts: ctx.artifacts,
538
690
  cacheFacts: () => ctx.cacheFacts(),
539
691
  run,
@@ -543,6 +695,7 @@ export function dte(options: DteOptions = {}) {
543
695
  (tails ??= tailsMs(nodes ?? new Map(), (x) => peaks.get(x)?.p50DurationMs))(id),
544
696
  how,
545
697
  home,
698
+ joinedPlace,
546
699
  lost: (id) => {
547
700
  const s = sizeOf.get(id)
548
701
  if (s === undefined) return
@@ -583,6 +736,7 @@ export function dte(options: DteOptions = {}) {
583
736
  cpu: s.cpu,
584
737
  memory: s.memory,
585
738
  ...named(s.name),
739
+ ...(multi && s.platform !== undefined ? { place: s.platform } : {}),
586
740
  })
587
741
  sizeOf.set(id, s)
588
742
  return {
@@ -625,6 +779,8 @@ export function dte(options: DteOptions = {}) {
625
779
  // The run's deadline (`Plan.boundMs`), and the work its plans knew.
626
780
  let deadline: number | undefined
627
781
  const known = new Set<string>()
782
+ // The last plan, which stands while what it saw holds (`decide`).
783
+ const last: { forecast?: Forecast } = {}
628
784
  // A plan that needs a worker later plans again then.
629
785
  let again: ReturnType<typeof setTimeout> | undefined
630
786
  // The fewest workers that end the run's known work within a margin of
@@ -646,7 +802,14 @@ export function dte(options: DteOptions = {}) {
646
802
  const m = p50(id)
647
803
  return m === undefined ? undefined : m * slow
648
804
  }
649
- 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
+ )
650
813
  const final = adapter === undefined
651
814
  const p = decide({
652
815
  remaining,
@@ -665,8 +828,10 @@ export function dte(options: DteOptions = {}) {
665
828
  limitMs: deadline === undefined ? undefined : deadline - performance.now(),
666
829
  known,
667
830
  how,
668
- home,
831
+ home: multi ? (n: TaskNode) => placeOf(n.worker, n.platform) : home,
669
832
  queued: o.queued(warmUp),
833
+ last,
834
+ now: performance.now(),
670
835
  })
671
836
  deadline = performance.now() + p.boundMs
672
837
  for (const t of p.tasks) known.add(t.id)
@@ -787,6 +952,8 @@ export function dte(options: DteOptions = {}) {
787
952
  }
788
953
  if (needsIn === undefined) scheduled = forecast
789
954
  else forecast()
955
+ // Core asks the footer and the summary after teardown: the last run's graph.
956
+ const depsOf = (id: string): readonly string[] => (nodes ?? ran)?.get(id)?.deps ?? []
790
957
  return {
791
958
  name: 'dte',
792
959
  remote: true,
@@ -800,29 +967,42 @@ export function dte(options: DteOptions = {}) {
800
967
  // A worker answers a task's runtime probes before it runs it.
801
968
  runtime: true,
802
969
  // Its sizes' platforms; workers that join on their own, this machine's.
803
- platforms:
804
- sizes.length === 0 ? [hostPlatform()] : [...new Set(sizes.map((s) => s.platform))],
805
- // Room for the workers' cores and their prefetch; the orchestrator
806
- // queues the rest. Workers that join on their own are as many as join.
807
- capacity:
808
- adapter === undefined || sizes.some((s) => s.max === Infinity)
809
- ? JOINED_CAPACITY
810
- : Math.max(
811
- 1,
812
- 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,
813
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,
814
988
  execute: (req) => {
815
989
  // A miss: the run's known work grows.
816
990
  missed = true
817
991
  executed.add(req.taskId)
818
992
  replan()
819
- return o.execute(req)
993
+ return o.execute(multi ? { ...req, platform: placeOf(req.worker, req.platform)! } : req)
820
994
  },
821
- footer: () => o.footer(() => o.idealMs((id) => (nodes ?? ran)?.get(id)?.deps ?? [], sizes)),
995
+ footer: () =>
996
+ o.footer(
997
+ () => o.idealMs(depsOf, sizes),
998
+ () => o.advice(depsOf, sizes),
999
+ ),
822
1000
  machines: () => o.machines(),
823
- idealMs: () => o.idealMs((id) => (nodes ?? ran)?.get(id)?.deps ?? [], sizes),
1001
+ idealMs: () => o.idealMs(depsOf, sizes),
1002
+ advice: () => o.advice(depsOf, sizes),
824
1003
  profile: () => o.profile(),
825
1004
  running: (id) => o.where(id),
1005
+ unneeded: (id) => o.unneeded(id),
826
1006
  demand(remaining, misses) {
827
1007
  if (pending === undefined) o.probed(remaining.size, misses.size)
828
1008
  o.missed = misses
@@ -928,6 +1108,28 @@ function refuse(option: string, want: string, got: unknown): never {
928
1108
  throw new UserError(`dte() option "${option}" must be ${want}, got ${said}`)
929
1109
  }
930
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
+
931
1133
  /** What a task asks of a worker, from its history. */
932
1134
  export function needOf(r: TaskResources | undefined, ask: Need = FRESH): Need {
933
1135
  // Unmeasured (new, or over before the Linux sandbox's first look, which
@@ -949,9 +1151,6 @@ export function needOf(r: TaskResources | undefined, ask: Need = FRESH): Need {
949
1151
  return { cores: Math.max(0.25, r.maxCpuParallelism), memory: r.maxPeakRssBytes ?? 0 }
950
1152
  }
951
1153
 
952
- /** The executor's pool when workers are uncapped: more than any run starts at once. */
953
- const JOINED_CAPACITY = 1024
954
-
955
1154
  const UNITS: Record<string, number> = {
956
1155
  B: 1,
957
1156
  KB: 1e3,
@@ -1022,6 +1221,16 @@ export function homeOf(sizes: readonly Size[], host: string): string | undefined
1022
1221
  return of.has(host) && of.size > 1 ? host : undefined
1023
1222
  }
1024
1223
 
1224
+ /**
1225
+ * Why a relay url no route can be appended to is refused: past a `?` or
1226
+ * `#` every socket asked the relay's root, and one naming /v1 asked /v1/v1/….
1227
+ */
1228
+ function unroutable(url: string): string | undefined {
1229
+ if (/[?#]/.test(url)) return 'takes no ?query or #fragment: each route is appended to it'
1230
+ if (/\/v1\/*$/.test(url)) return "is the relay's base, without /v1"
1231
+ return undefined
1232
+ }
1233
+
1025
1234
  /** A relay URL as a refusal may print it: a `user:pass@` would reach the CI log. */
1026
1235
  function shown(url: string): string {
1027
1236
  return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/i, '$1***@')