@vzn/vx-dte 0.0.0 → 0.0.649

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/planner.ts ADDED
@@ -0,0 +1,988 @@
1
+ // How many workers a run's rest needs, and which idle ones it can let go:
2
+ // its known work played out on the workers it has and on N more, as the
3
+ // orchestrator would place it, for each N. The run takes the fewest that
4
+ // end within a margin of the best, and plays again at each event: a miss
5
+ // found, a task done, a worker joined, history read (2026-10-23: "its
6
+ // a TETRIS game. every time we get new data we refine next step").
7
+
8
+ import type { TaskNode } from '@vzn/vx'
9
+ import {
10
+ assign,
11
+ dead,
12
+ kindOf,
13
+ ordered,
14
+ type Ask,
15
+ type Bin,
16
+ type Bounds,
17
+ type Need,
18
+ } from './dispatch.js'
19
+ import { DEFAULT_STRATEGY, type Strategy } from './strategy.js'
20
+
21
+ /**
22
+ * A task's duration when history has none: a test suite's, so a run with
23
+ * no history starts workers for its work rather than one for all of it.
24
+ */
25
+ const UNKNOWN_MS = 30_000
26
+ /** A worker's warm-up before one has joined: CI's took 15 to 23 s (main, 2026-10-23). */
27
+ export const WARM_UP_MS = 20_000
28
+ /** How much later than the best a plan may end: a few seconds, or 5%. */
29
+ const MARGIN_MS = 5_000
30
+ const MARGIN = 0.05
31
+
32
+ /** One kind of worker, as `dte()` resolved it: memory in bytes, `max` Infinity when uncapped. */
33
+ export interface Size {
34
+ readonly name: string
35
+ readonly max: number
36
+ readonly cpu: number
37
+ readonly memory: number
38
+ /** Its machines' platform (`WorkerSize.platform`). */
39
+ readonly platform?: string | undefined
40
+ }
41
+
42
+ /** A task to place: what it asks of a worker, how long it takes, and the plan's tasks it waits on. */
43
+ export interface PlanTask {
44
+ readonly id: string
45
+ readonly cores: number
46
+ readonly memory: number
47
+ /** It filled a machine this big last time: only a bigger one takes it, if any worker is. */
48
+ readonly above?: { readonly cores: number; readonly memory: number } | undefined
49
+ readonly ms: number | undefined
50
+ readonly deps: readonly string[]
51
+ /** The platform core placed it on (`TaskNode.platform`): only a worker of it runs it. */
52
+ readonly platform?: string | undefined
53
+ }
54
+
55
+ /** A worker the plan can place on: from `at` ms on, with what it runs until each ends. */
56
+ export interface PlanWorker {
57
+ readonly id: string
58
+ readonly cores: number
59
+ readonly memory: number
60
+ readonly at: number
61
+ readonly running: readonly {
62
+ /** The task: one the plan's tasks wait on waits until it ends. */
63
+ readonly id?: string
64
+ readonly cores: number
65
+ readonly memory: number
66
+ readonly endsIn: number
67
+ }[]
68
+ /** Holding no task: the run may let it go. */
69
+ readonly idle: boolean
70
+ /** Started and not joined yet: the run may cancel it. */
71
+ readonly starting?: boolean
72
+ readonly platform?: string | undefined
73
+ /** The size it was started as (`Size.name`): what its warm-up is learned by. */
74
+ readonly size?: string | undefined
75
+ }
76
+
77
+ /** A worker's warm-up, one for all, or by its size (`Size.name`; none for one that joined on its own). */
78
+ export type WarmUp = number | ((size: string | undefined) => number)
79
+
80
+ const warmOf = (w: WarmUp): ((size: string | undefined) => number) =>
81
+ typeof w === 'number' ? () => w : w
82
+
83
+ export interface Plan {
84
+ /** Sizes to start now, in order: each the plan gives work within its warm-up. */
85
+ readonly start: readonly Size[]
86
+ /** Idle workers the rest does not need before a new one could join. */
87
+ readonly release: readonly string[]
88
+ /** When the rest ends, from now. */
89
+ readonly endsInMs: number
90
+ /**
91
+ * The latest it may end, from now: the run's deadline, pass it back as
92
+ * `limitMs` while no new work is known, so the margin is spent once.
93
+ */
94
+ readonly boundMs: number
95
+ /** When to plan again for a worker the plan needs later, from now; undefined for none. */
96
+ readonly againInMs: number | undefined
97
+ }
98
+
99
+ /** History the run's finished tasks must have had before `slowdown` says anything. */
100
+ const SLOWDOWN_MS = 20_000
101
+
102
+ /**
103
+ * How much longer than history the run's finished tasks took (`took` ms,
104
+ * `forecast` ms), at least 1, once they had 20 s of history: the plan
105
+ * stretches what is left by it. A task past its forecast was planned to
106
+ * end now, so a run whose tasks took twice theirs planned 27 s left for
107
+ * 100 s, asked a worker, cancelled it as not needed, and asked again (PR
108
+ * #4192, 2026-10-23).
109
+ */
110
+ export function slowdown(done: Iterable<{ took: number; forecast: number | undefined }>): number {
111
+ let took = 0
112
+ let forecast = 0
113
+ for (const d of done)
114
+ if (d.forecast !== undefined && d.forecast > 0) {
115
+ took += d.took
116
+ forecast += d.forecast
117
+ }
118
+ return forecast < SLOWDOWN_MS ? 1 : Math.max(1, took / forecast)
119
+ }
120
+
121
+ /**
122
+ * Each task's tail: its usual duration and its longest chain of
123
+ * dependants after it, the most of the run that waits on it starting.
124
+ */
125
+ export function tailsMs(
126
+ nodes: ReadonlyMap<string, TaskNode>,
127
+ durationMs: (id: string) => number | undefined,
128
+ ): (id: string) => number {
129
+ const dependants = new Map<string, string[]>()
130
+ for (const [id, n] of nodes)
131
+ for (const d of n.deps) {
132
+ const list = dependants.get(d)
133
+ if (list === undefined) dependants.set(d, [id])
134
+ else list.push(id)
135
+ }
136
+ const tail = new Map<string, number>()
137
+ const visit = (id: string): number => {
138
+ const known = tail.get(id)
139
+ if (known !== undefined) return known
140
+ tail.set(id, 0)
141
+ const runs = nodes.get(id)?.config.exec?.command !== undefined
142
+ let after = 0
143
+ for (const d of dependants.get(id) ?? []) after = Math.max(after, visit(d))
144
+ const t = after + (runs ? (durationMs(id) ?? UNKNOWN_MS) : 0)
145
+ tail.set(id, t)
146
+ return t
147
+ }
148
+ return visit
149
+ }
150
+
151
+ /**
152
+ * `ids`' tasks as the plan takes them: each that runs a command, with
153
+ * what it asks and the tasks it waits on through the groups between.
154
+ */
155
+ export function planTasks(
156
+ ids: Iterable<string>,
157
+ nodes: ReadonlyMap<string, TaskNode>,
158
+ need: (id: string) => Need,
159
+ durationMs: (id: string) => number | undefined,
160
+ home?: string,
161
+ ): PlanTask[] {
162
+ const runs = (id: string): boolean => nodes.get(id)?.config.exec?.command !== undefined
163
+ const ran = new Map<string, string[]>()
164
+ const depsOf = (id: string): string[] => {
165
+ const known = ran.get(id)
166
+ if (known !== undefined) return known
167
+ ran.set(id, [])
168
+ const out = (nodes.get(id)?.deps ?? []).flatMap((d) => (runs(d) ? [d] : depsOf(d)))
169
+ ran.set(id, out)
170
+ return out
171
+ }
172
+ return [...ids].filter(runs).map((id) => {
173
+ const n = need(id)
174
+ return {
175
+ id,
176
+ cores: n.ask?.cores ?? n.cores,
177
+ memory: n.ask?.memory ?? n.memory,
178
+ above: n.above,
179
+ ms: durationMs(id),
180
+ deps: depsOf(id),
181
+ platform: nodes.get(id)?.platform ?? home,
182
+ }
183
+ })
184
+ }
185
+
186
+ /**
187
+ * The run's known work: of `remaining`, each task that runs a command and
188
+ * missed the cache, handed over to run (`executed`), or waits on a cached
189
+ * one that did, its key folding that one's. An uncached task runs every
190
+ * time, so it is work before it is handed over, and changes no dependant's
191
+ * key (check.bun, under every install). Counted only once handed over,
192
+ * main e38d1d2's first plan missed check.build-cold behind its install and
193
+ * asked one Mac for a 37 s lane that ran to 120 s (2026-10-24).
194
+ * Groups and hits are no work: counted, 85 hits of 109 tasks asked 6
195
+ * workers where 2 did (main, 2026-10-23).
196
+ */
197
+ export function missedWork(
198
+ remaining: Iterable<string>,
199
+ nodes: ReadonlyMap<string, TaskNode>,
200
+ executed: ReadonlySet<string>,
201
+ need: (id: string) => Need,
202
+ durationMs: (id: string) => number | undefined,
203
+ home?: string,
204
+ ): PlanTask[] {
205
+ const changed = new Map<string, boolean>()
206
+ // Whether `id`'s key changed: a cached task that missed, or one after it.
207
+ const changes = (id: string): boolean => {
208
+ const known = changed.get(id)
209
+ if (known !== undefined) return known
210
+ changed.set(id, false)
211
+ const n = nodes.get(id)
212
+ const c = (executed.has(id) && n?.config.cache !== undefined) || (n?.deps ?? []).some(changes)
213
+ changed.set(id, c)
214
+ return c
215
+ }
216
+ const missed = (id: string): boolean => {
217
+ const n = nodes.get(id)
218
+ return (
219
+ executed.has(id) ||
220
+ (n !== undefined && (n.config.cache === undefined || n.deps.some(changes)))
221
+ )
222
+ }
223
+ return planTasks([...remaining].filter(missed), nodes, need, durationMs, home)
224
+ }
225
+
226
+ /**
227
+ * The tasks a plan counts as missed: those handed over to run, the
228
+ * misses core's probe found, and of the commit's forecast (`likely`) only
229
+ * those still `remaining`. One the probe found held left `remaining`, and
230
+ * kept in the forecast its dependants counted as work: main e3c76a2 asked
231
+ * workers for 121 tasks, and 16 ran (2026-10-23).
232
+ */
233
+ export function knownMisses(
234
+ likely: ReadonlySet<string>,
235
+ sure: ReadonlySet<string>,
236
+ executed: ReadonlySet<string>,
237
+ remaining: ReadonlySet<string>,
238
+ ): ReadonlySet<string> {
239
+ if (likely.size === 0 && sure.size === 0) return executed
240
+ return new Set([...[...likely].filter((id) => remaining.has(id)), ...sure, ...executed])
241
+ }
242
+
243
+ interface Slot extends Bin {
244
+ readonly at: number
245
+ /** When it is first given a task. */
246
+ first?: number
247
+ }
248
+
249
+ /**
250
+ * When `tasks` end on `workers`, each placed as soon as its dependencies
251
+ * end, longest tail first, on the worker it fits with fewest cores free:
252
+ * the orchestrator's own rule (`assign`). Infinity when one never fits.
253
+ */
254
+ export function simulate(
255
+ tasks: readonly PlanTask[],
256
+ workers: readonly PlanWorker[],
257
+ how: Strategy = DEFAULT_STRATEGY,
258
+ ): number {
259
+ return play(tasks, workers, how).end
260
+ }
261
+
262
+ /** `simulate`, with when each worker is first given a task. */
263
+ function play(
264
+ tasks: readonly PlanTask[],
265
+ workers: readonly PlanWorker[],
266
+ how: Strategy,
267
+ ): { end: number; first: Map<string, number>; ends: Map<string | undefined, number> } {
268
+ const byId = new Map(tasks.map((t) => [t.id, t]))
269
+ const dependants = new Map<string, string[]>()
270
+ const waits = new Map<string, number>()
271
+ // A dependency running now ends when it does; one ended or a hit, now.
272
+ const runningIds = new Set<string>()
273
+ for (const w of workers) for (const r of w.running) if (r.id !== undefined) runningIds.add(r.id)
274
+ for (const t of tasks) {
275
+ let n = 0
276
+ for (const d of t.deps)
277
+ if (byId.has(d) || runningIds.has(d)) {
278
+ n++
279
+ // Pushed, not copied: copying made one install under 9000 tasks
280
+ // 40 million copies a play (2026-10-25).
281
+ const list = dependants.get(d)
282
+ if (list === undefined) dependants.set(d, [t.id])
283
+ else list.push(t.id)
284
+ }
285
+ waits.set(t.id, n)
286
+ }
287
+ const tail = new Map<string, number>()
288
+ const tailOf = (id: string): number => {
289
+ const known = tail.get(id)
290
+ if (known !== undefined) return known
291
+ let after = 0
292
+ for (const d of dependants.get(id) ?? []) after = Math.max(after, tailOf(d))
293
+ const t = after + (byId.get(id)!.ms ?? UNKNOWN_MS)
294
+ tail.set(id, t)
295
+ return t
296
+ }
297
+ const slots: Slot[] = workers.map((w) => ({
298
+ id: w.id,
299
+ cores: w.cores,
300
+ memory: w.memory,
301
+ platform: w.platform,
302
+ at: w.at,
303
+ used: { cores: 0, memory: 0 },
304
+ running: 0,
305
+ }))
306
+ // Ends to come: [at, slot, cores, memory, task or undefined].
307
+ const ends: [number, Slot, number, number, string | undefined][] = []
308
+ workers.forEach((w, i) => {
309
+ const s = slots[i]!
310
+ for (const r of w.running) {
311
+ s.used.cores += r.cores
312
+ s.used.memory += r.memory
313
+ s.running++
314
+ ends.push([Math.max(0, r.endsIn), s, r.cores, r.memory, r.id])
315
+ }
316
+ })
317
+ // Ready tasks, ordered again only once one joins (`graphene`: or
318
+ // leaves): each step sorted every ready task, and a run of 9000 spent
319
+ // its orchestrator planning (2026-10-25).
320
+ const ready = new Ready(tasks)
321
+ let joining = tasks.filter((t) => waits.get(t.id) === 0)
322
+ let readyCount = joining.length
323
+ const spent = new Set<string>()
324
+ const bounds: Bounds = { done: false, open: true, free: 0, cores: 0, memory: 0 }
325
+ // Ready tasks by what they need: no ready task needs less than the
326
+ // least of these, and a pass ends once no slot takes that.
327
+ const needs = new Map<string, { cores: number; memory: number; n: number }>()
328
+ // Their least, read again only once a kind on it leaves: each step read
329
+ // every kind, and 9000 kinds made it quadratic (2026-10-25).
330
+ let floor: { cores: number; memory: number } | undefined
331
+ const count = (t: PlanTask, by: number): void => {
332
+ const k = ready.kindOf(t.id)
333
+ const e = needs.get(k)
334
+ if (e === undefined) {
335
+ needs.set(k, { cores: t.cores, memory: t.memory, n: by })
336
+ if (floor !== undefined)
337
+ floor = { cores: Math.min(floor.cores, t.cores), memory: Math.min(floor.memory, t.memory) }
338
+ } else if ((e.n += by) === 0) {
339
+ needs.delete(k)
340
+ if (floor !== undefined && (e.cores <= floor.cores || e.memory <= floor.memory))
341
+ floor = undefined
342
+ }
343
+ }
344
+ for (const t of joining) count(t, 1)
345
+ const widest = Math.max(1, ...workers.map((w) => w.cores))
346
+ let left = tasks.length
347
+ let now = 0
348
+ let last = Math.max(0, ...ends.map((e) => e[0]))
349
+ // When each platform's last task ends.
350
+ const byPlatform = new Map<string | undefined, number>()
351
+ for (;;) {
352
+ if (joining.length > 0 || ready.stale) {
353
+ // By the longest chain, those still ready keep their order and the
354
+ // joining are merged in: sorted again with each join, a 9000-task
355
+ // tree sorted 17 million a plan (2026-10-25).
356
+ const merge = how.order === 'critical-path' && !ready.stale
357
+ const order = ordered(
358
+ (merge ? joining : [...ready.live(), ...joining]).map((t) => t.id),
359
+ how.order,
360
+ tailOf,
361
+ (id) => byId.get(id)!,
362
+ widest,
363
+ )
364
+ if (merge) ready.add(order, tailOf)
365
+ else ready.reset(order)
366
+ joining = []
367
+ }
368
+ const joined = slots.filter((s) => s.at <= now)
369
+ // A task that filled a size waits for a bigger one still to join.
370
+ const later = (above: { cores: number; memory: number }, platform?: string): boolean =>
371
+ slots.some(
372
+ (s) =>
373
+ s.at > now &&
374
+ (platform === undefined || s.platform === platform) &&
375
+ (s.cores > above.cores || s.memory > above.memory),
376
+ )
377
+ const to = assign(ready.asks(spent, bounds, needs.size), joined, later, how.place, {
378
+ floor: (floor ??= floorOf(needs.values())),
379
+ kinds: needs.size,
380
+ spent,
381
+ bounds,
382
+ })
383
+ // `graphene` ranks against the longest chain still ready.
384
+ if (to.size > 0 && how.order === 'graphene') ready.stale = true
385
+ for (const [id, best] of to) {
386
+ const t = byId.get(id)!
387
+ ready.take(id)
388
+ readyCount--
389
+ count(t, -1)
390
+ best.first ??= now
391
+ const end = now + (t.ms ?? UNKNOWN_MS)
392
+ ends.push([end, best, Math.min(t.cores, best.cores), Math.min(t.memory, best.memory), t.id])
393
+ last = Math.max(last, end)
394
+ byPlatform.set(best.platform, Math.max(byPlatform.get(best.platform) ?? 0, end))
395
+ }
396
+ const first = (): Map<string, number> =>
397
+ new Map(slots.flatMap((s) => (s.first === undefined ? [] : [[s.id, s.first] as const])))
398
+ if (left === 0 && readyCount === 0) return { end: last, first: first(), ends: byPlatform }
399
+ // The next moment anything changes: a task ends or a worker joins.
400
+ let next = Infinity
401
+ for (const e of ends) next = Math.min(next, e[0])
402
+ for (const s of slots) if (s.at > now) next = Math.min(next, s.at)
403
+ if (next === Infinity)
404
+ return { end: left === 0 ? last : Infinity, first: first(), ends: byPlatform }
405
+ now = next
406
+ for (let i = 0; i < ends.length;) {
407
+ const [at, s, cores, memory, id] = ends[i]!
408
+ if (at > now) {
409
+ i++
410
+ continue
411
+ }
412
+ ends.splice(i, 1)
413
+ s.used.cores -= cores
414
+ s.used.memory -= memory
415
+ s.running--
416
+ if (id === undefined) continue
417
+ if (byId.has(id)) left--
418
+ for (const d of dependants.get(id) ?? []) {
419
+ const n = waits.get(d)! - 1
420
+ waits.set(d, n)
421
+ if (n === 0) {
422
+ joining.push(byId.get(d)!)
423
+ readyCount++
424
+ count(byId.get(d)!, 1)
425
+ }
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ /**
432
+ * The workers to start and to let go. The fewest new ones (`candidates`,
433
+ * in the order the run would start them, each joining after its size's `warmUpMs`)
434
+ * whose plan ends within a margin of the best; of those, the ones it gives
435
+ * work within their warm-up start now, the rest when that nears
436
+ * (`againInMs`): a graph that widens later starts its workers later
437
+ * (2026-10-23). An idle worker goes when the plan still ends in that margin
438
+ * with one started in its place a warm-up later, joining after its own:
439
+ * needed again sooner, it stays, so none is let go and started again.
440
+ */
441
+ export function plan(
442
+ tasks: readonly PlanTask[],
443
+ workers: readonly PlanWorker[],
444
+ candidates: readonly Size[],
445
+ warmUpMs: WarmUp,
446
+ // Whether the run can start one in a let-go one's place: not with
447
+ // workers that join on their own.
448
+ replaceable = true,
449
+ // The latest an earlier plan of the same work allowed, from now.
450
+ limitMs = Infinity,
451
+ how: Strategy = DEFAULT_STRATEGY,
452
+ ): Plan {
453
+ const warm = warmOf(warmUpMs)
454
+ const fresh = (
455
+ s: { cpu: number; memory: number; platform?: string | undefined; size?: string | undefined },
456
+ id: string,
457
+ at: number,
458
+ ): PlanWorker => ({
459
+ id,
460
+ cores: s.cpu,
461
+ memory: s.memory,
462
+ platform: s.platform,
463
+ ...(s.size === undefined ? {} : { size: s.size }),
464
+ at,
465
+ running: [],
466
+ idle: false,
467
+ })
468
+ const added = candidates.map((s, i) => fresh({ ...s, size: s.name }, `\0new${i}`, warm(s.name)))
469
+ const ends: number[] = []
470
+ for (let n = 0; n <= candidates.length; n++) {
471
+ ends.push(simulate(tasks, [...workers, ...added.slice(0, n)], how))
472
+ // Past the plan's own length more cannot end it sooner.
473
+ if (n > 0 && ends[n] === ends[n - 1] && ends[n]! <= added[n - 1]!.at) break
474
+ }
475
+ const best = Math.min(...ends)
476
+ // Margin spent at each decision adds up: releases 4 s apart each lost
477
+ // 4 s of 5 (tests/sim.test.ts, a growing graph). Spent once, unless the
478
+ // rest can no longer end by then.
479
+ const bound =
480
+ best === Infinity
481
+ ? Infinity
482
+ : Math.max(best, Math.min(best + Math.max(MARGIN_MS, best * MARGIN), limitMs))
483
+ const n = Math.max(
484
+ 0,
485
+ ends.findIndex((e) => e <= bound),
486
+ )
487
+ let kept = [...workers, ...added.slice(0, n)]
488
+ // Sizes come in turns, so the prefix can hold one the plan ends as soon
489
+ // without: a second Mac the plan needed came after a second Linux worker
490
+ // it did not, and main e118ccf cancelled that one unjoined (2026-10-24).
491
+ // Dropped only when neither the end nor its platform's moves past the
492
+ // margin: to the run's end alone, a 110 s guess of an unmeasured chain
493
+ // cut every other platform to it.
494
+ let before = best < Infinity && n > 0 ? play(tasks, kept, how) : undefined
495
+ if (before !== undefined)
496
+ for (let i = n - 1; i >= 0; i--) {
497
+ const platform = added[i]!.platform
498
+ const without = kept.filter((k) => k !== added[i])
499
+ const after = play(tasks, without, how)
500
+ if (
501
+ after.end <= before.end &&
502
+ (after.ends.get(platform) ?? 0) <= (before.ends.get(platform) ?? 0) + MARGIN_MS
503
+ ) {
504
+ kept = without
505
+ before = after
506
+ }
507
+ }
508
+ const release: string[] = []
509
+ // Played with the rest: left out, the next idle one and the plan's end
510
+ // counted on a worker the run lets go.
511
+ const standIns: PlanWorker[] = []
512
+ if (best < Infinity) {
513
+ // One starting that the plan ends as well without is cancelled before
514
+ // it joins, last to join first; none is started in its place: a new
515
+ // one of its shape is dropped instead. Main c27c238 cancelled four
516
+ // 17 s into their warm-up and asked seven new ones at once.
517
+ const starting = workers.filter((w) => w.starting === true).sort((a, b) => b.at - a.at)
518
+ for (const w of starting) {
519
+ const twin = kept.find(
520
+ (k) =>
521
+ k.id.startsWith('\0new') &&
522
+ k.cores === w.cores &&
523
+ k.memory === w.memory &&
524
+ k.platform === w.platform,
525
+ )
526
+ const without = kept.filter((k) => k !== (twin ?? w))
527
+ // Asked, it goes only when the plan gives it no task: within the
528
+ // margin, the next plan's estimates moved a second later and
529
+ // cancelled 7 asked one at a time (PR #4214); ending the run no
530
+ // sooner, main 005e8f2 cancelled a mac and asked one again 18 s on.
531
+ if (
532
+ twin === undefined
533
+ ? !play(tasks, kept, how).first.has(w.id)
534
+ : simulate(tasks, without, how) <= bound
535
+ ) {
536
+ kept = without
537
+ if (twin === undefined) release.push(w.id)
538
+ }
539
+ }
540
+ // Biggest first: a big idle one costs the most to keep. Joined, its
541
+ // warm-up is spent: it goes when the plan gives it no task, not when
542
+ // the run ends within the margin without it (main e05bd71 let a mac go
543
+ // that way and asked another 4 s later).
544
+ const idle = workers.filter((w) => w.idle).sort((a, b) => b.cores - a.cores)
545
+ // A stand-in is one of the run's next starts of its size, not kept for
546
+ // other work. With none (its size's `max` spent: one let go still
547
+ // counts), the arm worker of a max-1 size went idle between two arm
548
+ // tasks, was let go, and the second found no worker (fuzz, 2026-10-25).
549
+ const spare = new Map<string, number>()
550
+ candidates.forEach((s, i) => {
551
+ if (!kept.includes(added[i]!)) spare.set(s.name, (spare.get(s.name) ?? 0) + 1)
552
+ })
553
+ for (const w of idle) {
554
+ const without = kept.filter((k) => k !== w)
555
+ const next = play(tasks, [...kept, ...standIns], how).first.get(w.id)
556
+ if (next === undefined) {
557
+ kept = without
558
+ release.push(w.id)
559
+ continue
560
+ }
561
+ // Needed again, it goes only when its next task is two warm-ups
562
+ // away: a stand-in then starts a warm-up after it left. Sooner, the
563
+ // next plan starts one at once: let go as 10 joined at 20 s, one was
564
+ // started again at 20.06 (tests/sim.test.ts, a09b987 forecast).
565
+ if (!replaceable) continue
566
+ if (next < 2 * warm(w.size) + MARGIN_MS) continue
567
+ const left = w.size === undefined ? 0 : (spare.get(w.size) ?? 0)
568
+ if (left === 0) continue
569
+ const instead = fresh(
570
+ { cpu: w.cores, memory: w.memory, platform: w.platform, size: w.size },
571
+ w.id,
572
+ next,
573
+ )
574
+ if (simulate(tasks, [...without, ...standIns, instead], how) <= bound) {
575
+ kept = without
576
+ release.push(w.id)
577
+ standIns.push(instead)
578
+ spare.set(w.size!, left - 1)
579
+ }
580
+ }
581
+ }
582
+ const { end, first } = play(tasks, [...kept, ...standIns], how)
583
+ const start: Size[] = []
584
+ let againInMs: number | undefined
585
+ for (let i = 0; i < n; i++) {
586
+ const at = first.get(added[i]!.id)
587
+ if (at === undefined) continue
588
+ // Given work within its warm-up and a margin: it starts now.
589
+ const warmUp = added[i]!.at
590
+ // Within a millisecond counts: `againInMs` of 1e-10 replanned the
591
+ // same plan 100,000 times at one instant (tests/sim.test.ts).
592
+ if (at < warmUp + MARGIN_MS + 1) start.push(candidates[i]!)
593
+ else againInMs = Math.min(againInMs ?? Infinity, at - warmUp - MARGIN_MS)
594
+ }
595
+ return { start, release, endsInMs: end, boundMs: bound, againInMs }
596
+ }
597
+
598
+ /**
599
+ * The sizes the run would start next, in order: for each task that
600
+ * filled its worker last time (`above`), one of the smallest size of its platform bigger
601
+ * than it while the run has none of that size; for each platform tasks
602
+ * run on, its sizes in declared order, up to one per task of it; then
603
+ * sizes in declared order up to each one's max.
604
+ */
605
+ export function candidates(
606
+ sizes: readonly Size[],
607
+ started: ReadonlyMap<string, number>,
608
+ above: Iterable<{ cores: number; memory: number; platform?: string | undefined }>,
609
+ limit: number,
610
+ platforms: ReadonlyMap<string, number> = new Map(),
611
+ ): Size[] {
612
+ const count = new Map(started)
613
+ const out: Size[] = []
614
+ const add = (s: Size): void => {
615
+ count.set(s.name, (count.get(s.name) ?? 0) + 1)
616
+ out.push(s)
617
+ }
618
+ const up = [...sizes].sort((x, y) => x.cpu - y.cpu || x.memory - y.memory)
619
+ for (const a of above) {
620
+ const z = up.find(
621
+ (s) =>
622
+ (a.platform === undefined || s.platform === a.platform) &&
623
+ (s.cpu > a.cores || s.memory > a.memory),
624
+ )
625
+ if (z !== undefined && (count.get(z.name) ?? 0) === 0 && z.max > 0) add(z)
626
+ }
627
+ // A platform the declared order reaches late gets its workers too, one
628
+ // a round each: listed after all of another's, a plan took twelve Linux
629
+ // workers to reach the Mac one (main c27c238 asked 13, cancelled 7).
630
+ const wants = new Map(platforms)
631
+ for (let added = true; added;) {
632
+ added = false
633
+ for (const [platform, tasks] of wants) {
634
+ if (tasks === 0) continue
635
+ const s = sizes.find((x) => x.platform === platform && (count.get(x.name) ?? 0) < x.max)
636
+ if (s === undefined) continue
637
+ add(s)
638
+ wants.set(platform, tasks - 1)
639
+ added = true
640
+ }
641
+ }
642
+ for (const s of sizes) while (out.length < limit && (count.get(s.name) ?? 0) < s.max) add(s)
643
+ return out
644
+ }
645
+
646
+ /**
647
+ * What the run does now, from all it knows: its known work (`missedWork`)
648
+ * less what runs already, played on its workers and on the candidates it
649
+ * may start (`plan`). The live run (`plugin.ts`) and the simulation that
650
+ * holds it to its invariants (tests/sim.ts) both decide here.
651
+ */
652
+ export function decide(input: {
653
+ readonly remaining: Iterable<string>
654
+ readonly nodes: ReadonlyMap<string, TaskNode>
655
+ readonly executed: ReadonlySet<string>
656
+ readonly need: (id: string) => Need
657
+ readonly durationMs: (id: string) => number | undefined
658
+ readonly workers: readonly PlanWorker[]
659
+ readonly running: ReadonlySet<string>
660
+ readonly sizes: readonly Size[]
661
+ readonly started: ReadonlyMap<string, number>
662
+ /** No worker may be started: a planned run's last, or one without an adapter. */
663
+ readonly final: boolean
664
+ readonly warmUpMs: WarmUp
665
+ readonly replaceable: boolean
666
+ /** The last plan's `boundMs`, less the time since: kept while `known` holds all its work. */
667
+ readonly limitMs?: number | undefined
668
+ /** The work earlier plans knew. */
669
+ readonly known?: ReadonlySet<string>
670
+ /** How it orders, places and scales (`need` books already). */
671
+ readonly how?: Strategy
672
+ /** The platform a task naming none runs on (`homeOf`). */
673
+ readonly home?: string | undefined
674
+ /**
675
+ * Sizes with a worker asked and not joined past its warm-up: the CI
676
+ * queued it, and one asked now queues behind it. None of them starts.
677
+ */
678
+ readonly queued?: ReadonlySet<string>
679
+ }): Plan & { readonly tasks: readonly PlanTask[] } {
680
+ const how = input.how ?? DEFAULT_STRATEGY
681
+ const tasks = missedWork(
682
+ input.remaining,
683
+ input.nodes,
684
+ input.executed,
685
+ input.need,
686
+ input.durationMs,
687
+ input.home,
688
+ ).filter((t) => !input.running.has(t.id))
689
+ const platforms = new Map<string, number>()
690
+ for (const t of tasks)
691
+ if (t.platform !== undefined) platforms.set(t.platform, (platforms.get(t.platform) ?? 0) + 1)
692
+ const sizes = input.final
693
+ ? []
694
+ : candidates(
695
+ input.queued === undefined
696
+ ? input.sizes
697
+ : input.sizes.filter((s) => !input.queued!.has(s.name)),
698
+ input.started,
699
+ tasks.flatMap((t) => (t.above === undefined ? [] : [{ ...t.above, platform: t.platform }])),
700
+ tasks.length,
701
+ platforms,
702
+ )
703
+ if (how.scale === 'backlog')
704
+ return { ...backlog(tasks, input.workers, sizes, input.warmUpMs, how), tasks }
705
+ const p = plan(
706
+ tasks,
707
+ input.workers,
708
+ sizes,
709
+ input.warmUpMs,
710
+ input.replaceable,
711
+ // New work moves the deadline; work done or known already does not.
712
+ tasks.every((t) => input.known?.has(t.id) === true) ? input.limitMs : undefined,
713
+ how,
714
+ )
715
+ return { ...p, tasks }
716
+ }
717
+
718
+ /**
719
+ * Spark's dynamic allocation: workers enough for the cores that what is
720
+ * ready or running asks, started at once; an idle one past that leaves.
721
+ * No play-out: it sees only the work that waits now.
722
+ */
723
+ function backlog(
724
+ tasks: readonly PlanTask[],
725
+ workers: readonly PlanWorker[],
726
+ candidates: readonly Size[],
727
+ warmUpMs: WarmUp,
728
+ how: Strategy,
729
+ ): Plan {
730
+ const ids = new Set(tasks.map((t) => t.id))
731
+ const running = new Set<string>()
732
+ for (const w of workers) for (const r of w.running) if (r.id !== undefined) running.add(r.id)
733
+ const widest = Math.max(1, ...workers.map((w) => w.cores), ...candidates.map((c) => c.cpu))
734
+ // Cores asked and had, all told and of each platform a task names: only
735
+ // a worker of it runs such a task, so spare Linux cores left a Mac task
736
+ // waiting for a Mac worker never started.
737
+ let asked = 0
738
+ let have = 0
739
+ const askedOf = new Map<string, number>()
740
+ const haveOf = new Map<string, number>()
741
+ const add = (m: Map<string, number>, p: string | undefined, n: number): void => {
742
+ if (p !== undefined) m.set(p, (m.get(p) ?? 0) + n)
743
+ }
744
+ for (const t of tasks)
745
+ if (t.deps.every((d) => !ids.has(d) && !running.has(d))) {
746
+ const cores = Math.min(t.cores, widest)
747
+ asked += cores
748
+ add(askedOf, t.platform, cores)
749
+ }
750
+ for (const w of workers)
751
+ for (const r of w.running) {
752
+ asked += r.cores
753
+ add(askedOf, w.platform, r.cores)
754
+ }
755
+ for (const w of workers) {
756
+ have += w.cores
757
+ add(haveOf, w.platform, w.cores)
758
+ }
759
+ const short = (p: string | undefined): boolean =>
760
+ p !== undefined && (haveOf.get(p) ?? 0) < (askedOf.get(p) ?? 0) - 1e-9
761
+ const start: Size[] = []
762
+ for (const c of candidates) {
763
+ if (have >= asked - 1e-9 && !short(c.platform)) continue
764
+ start.push(c)
765
+ have += c.cpu
766
+ add(haveOf, c.platform, c.cpu)
767
+ }
768
+ const release: string[] = []
769
+ for (const w of workers)
770
+ if (
771
+ w.idle &&
772
+ have - w.cores >= asked - 1e-9 &&
773
+ (w.platform === undefined ||
774
+ (haveOf.get(w.platform) ?? 0) - w.cores >= (askedOf.get(w.platform) ?? 0) - 1e-9)
775
+ ) {
776
+ release.push(w.id)
777
+ have -= w.cores
778
+ add(haveOf, w.platform, -w.cores)
779
+ }
780
+ const kept = [
781
+ ...workers.filter((w) => !release.includes(w.id)),
782
+ ...start.map((c, i): PlanWorker => ({
783
+ id: `\0new${i}`,
784
+ cores: c.cpu,
785
+ memory: c.memory,
786
+ platform: c.platform,
787
+ size: c.name,
788
+ at: warmOf(warmUpMs)(c.name),
789
+ running: [],
790
+ idle: false,
791
+ })),
792
+ ]
793
+ const end = simulate(tasks, kept, how)
794
+ return { start, release, endsInMs: end, boundMs: end, againInMs: undefined }
795
+ }
796
+
797
+ /** The most kinds `Ready.asks` jumps across; past it, it reads every task. */
798
+ const MAX_JUMP_KINDS = 32
799
+
800
+ /**
801
+ * Ready tasks in the order `ordered` gave, by kind (`kindOf`): `assign`
802
+ * reads them in that order, a kind it found spent skipped in one jump,
803
+ * where it read every ready task each step (2026-10-25). A task is its
804
+ * place in the play's tasks, and its rank (`before`) is fixed once it
805
+ * joins: rebuilt with each join, a 9000-task tree hashed every ready
806
+ * task 17 million times a plan (2026-10-25).
807
+ */
808
+ class Ready {
809
+ private readonly ids: readonly string[]
810
+ private readonly at: Map<string, number>
811
+ private readonly kindAt: readonly string[]
812
+ private readonly taken: Uint8Array
813
+ /** The rank: the lower `primary` first, then the lower `seq`. */
814
+ private readonly primary: Float64Array
815
+ private readonly seq: Float64Array
816
+ private next = 0
817
+ /** In rank order; taken ones linger until the next join. */
818
+ private order: number[] = []
819
+ private kinds = new Map<string, number[]>()
820
+ /** Each kind's first task not taken. */
821
+ private readonly head = new Map<string, number>()
822
+ /** No task before this place in `order` is left: a pass began at 0 and read 9000 taken ones (2026-10-25). */
823
+ private lo = 0
824
+ /** Order it again before the next step. */
825
+ stale = false
826
+
827
+ constructor(private readonly tasks: readonly PlanTask[]) {
828
+ this.ids = tasks.map((t) => t.id)
829
+ this.at = new Map(this.ids.map((id, i) => [id, i]))
830
+ this.kindAt = tasks.map(kindOf)
831
+ this.taken = new Uint8Array(tasks.length)
832
+ this.primary = new Float64Array(tasks.length)
833
+ this.seq = new Float64Array(tasks.length)
834
+ }
835
+
836
+ kindOf(id: string): string {
837
+ return this.kindAt[this.at.get(id)!]!
838
+ }
839
+
840
+ take(id: string): void {
841
+ this.taken[this.at.get(id)!] = 1
842
+ }
843
+
844
+ live(): PlanTask[] {
845
+ return this.order.flatMap((i) => (this.taken[i] === 1 ? [] : [this.tasks[i]!]))
846
+ }
847
+
848
+ /** Joins `ids`, longest tail first, behind every ready task of a tail as long. */
849
+ add(ids: readonly string[], tail: (id: string) => number): void {
850
+ const batch = ids.map((id) => {
851
+ const i = this.at.get(id)!
852
+ this.primary[i] = -tail(id)
853
+ this.seq[i] = this.next++
854
+ return i
855
+ })
856
+ this.join(batch)
857
+ }
858
+
859
+ /** Ready tasks are `ids` alone, in that order. */
860
+ reset(ids: readonly string[]): void {
861
+ this.order = []
862
+ this.kinds = new Map()
863
+ this.head.clear()
864
+ this.stale = false
865
+ const batch = ids.map((id) => {
866
+ const i = this.at.get(id)!
867
+ this.primary[i] = 0
868
+ this.seq[i] = this.next++
869
+ return i
870
+ })
871
+ this.join(batch)
872
+ }
873
+
874
+ private before(a: number, b: number): boolean {
875
+ const pa = this.primary[a]!
876
+ const pb = this.primary[b]!
877
+ return pa < pb || (pa === pb && this.seq[a]! < this.seq[b]!)
878
+ }
879
+
880
+ /** `a` without its taken ones, and `b`, in rank order. */
881
+ private merge(a: readonly number[], b: readonly number[]): number[] {
882
+ const out: number[] = []
883
+ let i = 0
884
+ let j = 0
885
+ while (i < a.length || j < b.length) {
886
+ if (i < a.length && this.taken[a[i]!] === 1) i++
887
+ else if (j === b.length || (i < a.length && this.before(a[i]!, b[j]!))) out.push(a[i++]!)
888
+ else out.push(b[j++]!)
889
+ }
890
+ return out
891
+ }
892
+
893
+ private join(batch: readonly number[]): void {
894
+ this.order = this.merge(this.order, batch)
895
+ this.lo = 0
896
+ const by = new Map<string, number[]>()
897
+ for (const i of batch) {
898
+ const k = this.kindAt[i]!
899
+ const q = by.get(k)
900
+ if (q === undefined) by.set(k, [i])
901
+ else q.push(i)
902
+ }
903
+ // Kinds keep the order's: a kind left with no task is gone, as one
904
+ // ordered again from scratch.
905
+ const kinds = new Map<string, number[]>()
906
+ for (const [k, q] of this.kinds) {
907
+ const merged = this.merge(q, by.get(k) ?? [])
908
+ by.delete(k)
909
+ if (merged.length > 0) kinds.set(k, merged)
910
+ }
911
+ for (const [k, q] of by) kinds.set(k, q)
912
+ this.kinds = kinds
913
+ this.head.clear()
914
+ }
915
+
916
+ /** In order, past each kind in `spent` as `assign` reads it then. */
917
+ *asks(spent: Set<string>, bounds?: Bounds, kinds?: number): Generator<Ask> {
918
+ // Measured needs are unrounded, so kinds can be as many as tasks: a
919
+ // jump across every kind then cost more than a read of every task.
920
+ if (this.kinds.size > MAX_JUMP_KINDS) {
921
+ while (this.lo < this.order.length && this.taken[this.order[this.lo]!] === 1) this.lo++
922
+ for (let p = this.lo; p < this.order.length; p++) {
923
+ const i = this.order[p]!
924
+ const kind = this.kindAt[i]!
925
+ if (this.taken[i] === 1 || spent.has(kind)) continue
926
+ if (bounds?.done === true || (kinds !== undefined && spent.size >= kinds)) return
927
+ const t = this.tasks[i]!
928
+ if (bounds !== undefined && dead(t, bounds)) spent.add(kind)
929
+ else yield { id: t.id, need: t, kind }
930
+ }
931
+ return
932
+ }
933
+ const at = new Map<string, number>()
934
+ let cursor = -1
935
+ for (;;) {
936
+ let next = -1
937
+ let nextKind = ''
938
+ for (const [k, q] of this.kinds) {
939
+ if (spent.has(k)) continue
940
+ let p = at.get(k) ?? this.headOf(k, q)
941
+ if (p < q.length && cursor !== -1 && !this.before(cursor, q[p]!))
942
+ p = this.after(q, p, cursor)
943
+ while (p < q.length && this.taken[q[p]!] === 1) p++
944
+ at.set(k, p)
945
+ if (p < q.length && (next === -1 || this.before(q[p]!, next))) {
946
+ next = q[p]!
947
+ nextKind = k
948
+ }
949
+ }
950
+ if (next === -1) return
951
+ at.set(nextKind, at.get(nextKind)! + 1)
952
+ cursor = next
953
+ const t = this.tasks[next]!
954
+ yield { id: t.id, need: t, kind: nextKind }
955
+ }
956
+ }
957
+
958
+ private headOf(k: string, q: readonly number[]): number {
959
+ let h = this.head.get(k) ?? 0
960
+ while (h < q.length && this.taken[q[h]!] === 1) h++
961
+ this.head.set(k, h)
962
+ return h
963
+ }
964
+
965
+ /** The first place from `p` in `q` ranked past `cursor`. */
966
+ private after(q: readonly number[], p: number, cursor: number): number {
967
+ let hi = q.length
968
+ while (p < hi) {
969
+ const mid = (p + hi) >> 1
970
+ if (!this.before(cursor, q[mid]!)) p = mid + 1
971
+ else hi = mid
972
+ }
973
+ return p
974
+ }
975
+ }
976
+
977
+ function floorOf(needs: Iterable<{ cores: number; memory: number }>): {
978
+ cores: number
979
+ memory: number
980
+ } {
981
+ let cores = Infinity
982
+ let memory = Infinity
983
+ for (const n of needs) {
984
+ cores = Math.min(cores, n.cores)
985
+ memory = Math.min(memory, n.memory)
986
+ }
987
+ return { cores, memory }
988
+ }