@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/LICENSE +21 -0
- package/README.md +923 -1
- package/index.ts +4 -0
- package/package.json +40 -4
- package/src/cpu-account.ts +134 -0
- package/src/dispatch.ts +268 -0
- package/src/docker.ts +156 -0
- package/src/forecast.ts +68 -0
- package/src/github.ts +523 -0
- package/src/gitlab.ts +130 -0
- package/src/index.ts +12 -0
- package/src/init.ts +522 -0
- package/src/local.ts +256 -0
- package/src/messages.ts +219 -0
- package/src/noise.ts +248 -0
- package/src/orchestrator.ts +2359 -0
- package/src/outputs.ts +117 -0
- package/src/planner.ts +988 -0
- package/src/plugin.ts +1020 -0
- package/src/printable.ts +11 -0
- package/src/seal.ts +509 -0
- package/src/strategy.ts +50 -0
- package/src/trace.ts +288 -0
- package/src/tree.ts +72 -0
- package/src/usage.ts +135 -0
- package/src/warm-ups.ts +85 -0
- package/src/wire.ts +1060 -0
- package/src/worker.ts +1038 -0
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
|
+
}
|