@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/README.md +164 -60
- package/package.json +3 -3
- package/src/dispatch.ts +540 -30
- package/src/forecast.ts +1 -2
- package/src/github.ts +0 -3
- package/src/index.ts +1 -0
- package/src/init.ts +9 -10
- package/src/local.ts +112 -28
- package/src/messages.ts +26 -5
- package/src/orchestrator.ts +678 -233
- package/src/planner.ts +1470 -192
- package/src/plugin.ts +242 -33
- package/src/seal.ts +159 -43
- package/src/server-fit.ts +80 -0
- package/src/tree.ts +215 -7
- package/src/usage.ts +65 -3
- package/src/warm-ups.ts +72 -6
- package/src/wire.ts +119 -49
- package/src/worker.ts +174 -19
package/src/orchestrator.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type SandboxConfig,
|
|
26
26
|
type TaskArtifacts,
|
|
27
27
|
type TaskNode,
|
|
28
|
+
type WorkerAdvice,
|
|
28
29
|
} from '@vzn/vx'
|
|
29
30
|
import {
|
|
30
31
|
bytesOf,
|
|
@@ -35,14 +36,24 @@ import {
|
|
|
35
36
|
type DepOrder,
|
|
36
37
|
type RunOrder,
|
|
37
38
|
type RuntimeOrder,
|
|
39
|
+
type SentOrder,
|
|
40
|
+
type WiredDep,
|
|
38
41
|
type ToWorker,
|
|
39
42
|
type Load,
|
|
40
43
|
type ToOrchestrator,
|
|
41
44
|
type WiredResult,
|
|
42
45
|
} from './messages.js'
|
|
43
|
-
import {
|
|
46
|
+
import {
|
|
47
|
+
advise,
|
|
48
|
+
simulate,
|
|
49
|
+
type Fleets,
|
|
50
|
+
type PlanTask,
|
|
51
|
+
type PlanWorker,
|
|
52
|
+
type Size,
|
|
53
|
+
type WarmUp,
|
|
54
|
+
} from './planner.js'
|
|
44
55
|
import { Usage, type UsageStats } from './usage.js'
|
|
45
|
-
import {
|
|
56
|
+
import { type Need, Queue } from './dispatch.js'
|
|
46
57
|
import { DEFAULT_STRATEGY, type Strategy } from './strategy.js'
|
|
47
58
|
import { RelayError, unsent, type Participant } from './wire.js'
|
|
48
59
|
import { ORCHESTRATOR, Trace } from './trace.js'
|
|
@@ -52,7 +63,8 @@ import { names } from './outputs.js'
|
|
|
52
63
|
|
|
53
64
|
interface Job {
|
|
54
65
|
readonly req: ExecuteRequest
|
|
55
|
-
|
|
66
|
+
/** Read as it joined the queue, and again while it waits when history changes (`rerank`). */
|
|
67
|
+
need: Need
|
|
56
68
|
/** The cores and memory it holds on its worker: its need, or the whole worker for one that filled its worker. */
|
|
57
69
|
held?: Need
|
|
58
70
|
readonly resolve: (r: ExecuteResult) => void
|
|
@@ -84,8 +96,15 @@ interface Job {
|
|
|
84
96
|
reread?: Set<string>
|
|
85
97
|
/** Its upstreams, once the graph named it: walked again at every pump, 8000 queued spent 5 ms a pump on it (2026-10-25). */
|
|
86
98
|
up?: Set<string>
|
|
99
|
+
/** Its direct dependencies, as placing reads them (`Ask.up`). */
|
|
100
|
+
deps?: readonly string[] | undefined
|
|
101
|
+
/** A retry: the worker its last attempt ran on, which it goes elsewhere than while another can take it. */
|
|
102
|
+
avoid?: string
|
|
87
103
|
}
|
|
88
104
|
|
|
105
|
+
/** An upstream's files, as an order names them (`DepOrder`). */
|
|
106
|
+
type DepFiles = Pick<DepOrder, 'project' | 'outputs'>
|
|
107
|
+
|
|
89
108
|
/** The longest a task waits on the worker holding its upstream's files before they are made again (2026-10-23: 10 s on any wait). */
|
|
90
109
|
const PIN_WAIT_MS = 10_000
|
|
91
110
|
|
|
@@ -123,6 +142,14 @@ interface Worker {
|
|
|
123
142
|
load?: { cpu: number; memory: number; at: number }
|
|
124
143
|
/** Tasks whose outputs it holds: it ran them, or was sent them. */
|
|
125
144
|
readonly has: Set<string>
|
|
145
|
+
/**
|
|
146
|
+
* The persistent tasks it runs, each with the base it books there
|
|
147
|
+
* (`Need.servers`): booked once, by the first task placed there that
|
|
148
|
+
* needs it, and given back when the run says none needs it.
|
|
149
|
+
*/
|
|
150
|
+
readonly serving: Map<string, number>
|
|
151
|
+
/** Each dependency it was sent whole, by the key it was sent under: the next order names it alone. */
|
|
152
|
+
readonly sentDeps: Map<string, string>
|
|
126
153
|
/** Sends to it go out in order: a run's parts before the run. */
|
|
127
154
|
chain: Promise<void>
|
|
128
155
|
/** It was sent the run's sandbox configs. */
|
|
@@ -138,6 +165,8 @@ export interface Declared {
|
|
|
138
165
|
readonly memory: number
|
|
139
166
|
/** Its size's platform: a worker reporting another is refused. None: planned as this machine's. */
|
|
140
167
|
readonly platform?: string
|
|
168
|
+
/** Where tasks match it when the run has several worker types: `<type>:<platform>`. */
|
|
169
|
+
readonly place?: string
|
|
141
170
|
}
|
|
142
171
|
|
|
143
172
|
export interface OrchestratorOptions {
|
|
@@ -147,6 +176,8 @@ export interface OrchestratorOptions {
|
|
|
147
176
|
readonly tree: string
|
|
148
177
|
readonly nodes: () => ReadonlyMap<string, TaskNode> | undefined
|
|
149
178
|
readonly need: (taskId: string) => Need
|
|
179
|
+
/** Each server's base, cores, as workers fit them: updated in place. */
|
|
180
|
+
readonly bases?: Map<string, number>
|
|
150
181
|
/** The run's cache, by key: what workers save to and this machine restores from. */
|
|
151
182
|
readonly artifacts: TaskArtifacts
|
|
152
183
|
/** What workers read the cache under (`ExecutorContext.cacheFacts`). */
|
|
@@ -177,6 +208,11 @@ export interface OrchestratorOptions {
|
|
|
177
208
|
readonly how?: Strategy
|
|
178
209
|
/** The platform a task naming none runs on (`homeOf`). */
|
|
179
210
|
readonly home?: string | undefined
|
|
211
|
+
/** A joined worker's place, by its size and the platform it reported: absent, the platform. */
|
|
212
|
+
readonly joinedPlace?: (
|
|
213
|
+
size: string | undefined,
|
|
214
|
+
platform: string | undefined,
|
|
215
|
+
) => string | undefined
|
|
180
216
|
/** No task began or ended for `stallMs`: what the run waits on, one line a task. */
|
|
181
217
|
readonly stalled?: (waiting: string) => void
|
|
182
218
|
/** A fault the run works around (a message the relay would not take); default stderr. */
|
|
@@ -207,6 +243,17 @@ const BATCH_BYTES = 512 * 1024
|
|
|
207
243
|
const FEW = 2
|
|
208
244
|
/** The unmeasured tasks the summary names; the rest it counts. */
|
|
209
245
|
const NAMED = 40
|
|
246
|
+
/** The footer's label of each `WorkerAdvice`. */
|
|
247
|
+
const ADVICE: Record<WorkerAdvice['of'], readonly [label: string, note: string]> = {
|
|
248
|
+
declared: ['best', 'the fewest of its sizes'],
|
|
249
|
+
more: ['more', "past its sizes' max"],
|
|
250
|
+
any: ['any', 'of any cores, the least core time'],
|
|
251
|
+
}
|
|
252
|
+
/** A platform as shown: a worker type's place (`ubuntu:linux-x64`) without its type, which its size names. */
|
|
253
|
+
const bare = (platform: string): string => platform.slice(platform.indexOf(':') + 1)
|
|
254
|
+
/** `3 × small (4c/16G linux)`. */
|
|
255
|
+
const fleetText = (w: WorkerAdvice['workers'][number]): string =>
|
|
256
|
+
`${w.count} × ${w.size === undefined ? '' : `${w.size} `}(${w.cores}c/${compact(w.memoryBytes)}${w.platform === undefined ? '' : ` ${w.platform}`})`
|
|
210
257
|
/** Core-time of history the run's tasks finish before their use reshapes bookings: a few tasks' worth. */
|
|
211
258
|
const LEARN_MS = 60_000
|
|
212
259
|
/** The most a worker's bookings may add up to, in its cores, when tasks use less than their history. */
|
|
@@ -215,12 +262,12 @@ const OVERBOOK = 1.5
|
|
|
215
262
|
const RESTARTS = 3
|
|
216
263
|
|
|
217
264
|
/** `orders` in runs of at most `max` bytes of JSON; one bigger goes alone. */
|
|
218
|
-
function parts<T extends {
|
|
265
|
+
function parts<T extends { bytes: number }>(orders: readonly T[], max: number): T[][] {
|
|
219
266
|
const out: T[][] = []
|
|
220
267
|
let part: T[] = []
|
|
221
268
|
let bytes = 0
|
|
222
269
|
for (const o of orders) {
|
|
223
|
-
const size =
|
|
270
|
+
const size = o.bytes
|
|
224
271
|
if (part.length > 0 && bytes + size > max) {
|
|
225
272
|
out.push(part)
|
|
226
273
|
part = []
|
|
@@ -259,9 +306,15 @@ export class Orchestrator {
|
|
|
259
306
|
private readonly workers = new Map<string, Worker>()
|
|
260
307
|
/** The run tree's files, workspace-relative, listed when a task first asks. */
|
|
261
308
|
private inTree?: Promise<Tree | undefined>
|
|
262
|
-
|
|
309
|
+
/** Waiting tasks, by id, in the order a pump reads them (`Queue`). */
|
|
310
|
+
private readonly waiting = new Map<string, Job>()
|
|
311
|
+
/** Handed by core and not yet waiting: held for a machine as the waiting are. */
|
|
312
|
+
private readonly handed = new Set<string>()
|
|
313
|
+
private readonly queue: Queue
|
|
263
314
|
/** Where a worker saved each task it ran: its cache key or its run key. */
|
|
264
315
|
private readonly savedAs = new Map<string, string>()
|
|
316
|
+
/** The worker each task last ran on, which its retry goes elsewhere than. */
|
|
317
|
+
private readonly ranOn = new Map<string, string>()
|
|
265
318
|
/** Keys the run's requests named for their dependencies. */
|
|
266
319
|
private readonly upstreamKeys = new Map<string, string>()
|
|
267
320
|
/** How many times each task was sent: its run key is one per send. */
|
|
@@ -288,6 +341,10 @@ export class Orchestrator {
|
|
|
288
341
|
private readonly retired = new Set<Worker>()
|
|
289
342
|
/** Workers asked for that never joined, with why. */
|
|
290
343
|
private readonly unstarted = new Map<string, string>()
|
|
344
|
+
/** A task waited here on a worker: one ended with none joined needed one. */
|
|
345
|
+
private waited = false
|
|
346
|
+
/** Why each worker this run refused was refused, in order. */
|
|
347
|
+
private readonly refused: string[] = []
|
|
291
348
|
/** Started, then not needed before they joined (`release`), or given up as late. */
|
|
292
349
|
private readonly cancelled = new Set<string>()
|
|
293
350
|
/** Of `cancelled`, those given up past their join limit, not let go: taken if they join. */
|
|
@@ -354,9 +411,12 @@ export class Orchestrator {
|
|
|
354
411
|
private relend: ReturnType<typeof setTimeout> | undefined
|
|
355
412
|
/** The plugins any of the run's lends lent a token. */
|
|
356
413
|
private readonly lenders = new Set<string>()
|
|
414
|
+
/** Each graph's upstreams as orders name them (`dep`). */
|
|
415
|
+
private readonly deps = new WeakMap<ReadonlyMap<string, TaskNode>, Map<string, DepFiles | null>>()
|
|
357
416
|
|
|
358
417
|
constructor(private readonly o: OrchestratorOptions) {
|
|
359
418
|
this.sent = o.sent ?? new Map()
|
|
419
|
+
this.queue = new Queue(o.how ?? DEFAULT_STRATEGY, o.tail, (id) => this.waiting.get(id)!.need)
|
|
360
420
|
// A root that is gone fails the run's own reads first.
|
|
361
421
|
this.realRoot = (() => {
|
|
362
422
|
try {
|
|
@@ -371,6 +431,32 @@ export class Orchestrator {
|
|
|
371
431
|
|
|
372
432
|
async execute(req: ExecuteRequest): Promise<ExecuteResult> {
|
|
373
433
|
if (this.lost !== undefined) throw new Error(this.lost)
|
|
434
|
+
this.handed.add(req.taskId)
|
|
435
|
+
try {
|
|
436
|
+
return await this.take(req)
|
|
437
|
+
} finally {
|
|
438
|
+
this.handed.delete(req.taskId)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private async take(req: ExecuteRequest): Promise<ExecuteResult> {
|
|
443
|
+
// Walked once here, for every later read: walked again on the way in,
|
|
444
|
+
// sharing, placing and sending, a run of 8000 tasks spent 17 s of this
|
|
445
|
+
// machine's CPU on it (2026-11-05).
|
|
446
|
+
const nodes = this.o.nodes()
|
|
447
|
+
const up = upstream(nodes, req.taskId)
|
|
448
|
+
// A worker runs a task in its project's directory of the run's tree: a
|
|
449
|
+
// project whose config is untracked is not there, and every worker
|
|
450
|
+
// failed to spawn it, exit 127 (2026-11-07).
|
|
451
|
+
const dir = path.relative(this.o.root, req.cwd)
|
|
452
|
+
if (dir !== '' && !dir.startsWith('..')) {
|
|
453
|
+
this.inTree ??= listTree(this.o.root, this.o.tree)
|
|
454
|
+
const tree = await this.inTree
|
|
455
|
+
if (tree !== undefined && !holds(tree, dir))
|
|
456
|
+
throw new UserError(
|
|
457
|
+
`${req.taskId} runs in ${dir}, which is not in the commit workers check out: commit its project (its vx.config file is untracked)`,
|
|
458
|
+
)
|
|
459
|
+
}
|
|
374
460
|
// A worker runs the run's tree: a file its key includes that the tree
|
|
375
461
|
// lacks (untracked) is not there, and the output it saved under that
|
|
376
462
|
// key was a stale hit for the next run (2026-10-24).
|
|
@@ -380,25 +466,16 @@ export class Orchestrator {
|
|
|
380
466
|
// A dependency's output is restored there before the task runs: a
|
|
381
467
|
// key that reads an upstream's bytes (`inputs.tasks` leaving it out)
|
|
382
468
|
// was refused (2026-10-25).
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
: [
|
|
390
|
-
names(path.relative(this.o.root, n.projectDir), {
|
|
391
|
-
files: outs.files,
|
|
392
|
-
workspaceFiles: outs.workspaceFiles ?? [],
|
|
393
|
-
}),
|
|
394
|
-
]
|
|
395
|
-
})
|
|
469
|
+
let restored: ((file: string) => boolean)[] | undefined
|
|
470
|
+
const made = (file: string): boolean =>
|
|
471
|
+
(restored ??= [...up].flatMap((id) => {
|
|
472
|
+
const d = this.dep(nodes, id)
|
|
473
|
+
return d === undefined ? [] : [names(d.project, d.outputs)]
|
|
474
|
+
})).some((m) => m(file))
|
|
396
475
|
const stray =
|
|
397
476
|
tree === undefined
|
|
398
477
|
? undefined
|
|
399
|
-
: req.inputs.files.find(
|
|
400
|
-
(f) => !tree.files.has(f.path) && !restored.some((made) => made(f.path)),
|
|
401
|
-
)
|
|
478
|
+
: req.inputs.files.find((f) => !tree.files.has(f.path) && !made(f.path))
|
|
402
479
|
if (stray !== undefined) {
|
|
403
480
|
// A committed submodule's files are not the tree's: its gitlink is.
|
|
404
481
|
const link = tree?.links.find((l) => stray.path.startsWith(`${l}/`))
|
|
@@ -408,22 +485,42 @@ export class Orchestrator {
|
|
|
408
485
|
: `${req.taskId} keys ${stray.path}, in submodule ${link}, whose files workers do not check out: leave it out of the task's inputs`,
|
|
409
486
|
)
|
|
410
487
|
}
|
|
488
|
+
// A worker runs the commit's bytes and modes (`unfilter`): a file on
|
|
489
|
+
// disk otherwise (CRLF a git filter wrote, an expanded ident, an LFS
|
|
490
|
+
// file's content) is not what it builds on, and the output saved
|
|
491
|
+
// under the key of this disk's bytes was a stale hit (2026-11-08).
|
|
492
|
+
const off =
|
|
493
|
+
tree === undefined
|
|
494
|
+
? undefined
|
|
495
|
+
: req.inputs.files.find((f) => {
|
|
496
|
+
const has = tree.files.get(f.path)
|
|
497
|
+
return has !== undefined && has !== f.digest && !made(f.path)
|
|
498
|
+
})
|
|
499
|
+
if (off !== undefined)
|
|
500
|
+
throw new UserError(
|
|
501
|
+
`${req.taskId} keys ${off.path}, which is on disk other than the commit workers check out holds it (a git filter's text, eol, ident or LFS bytes, or core.autocrlf): leave it out of the task's inputs, or run the task without dte`,
|
|
502
|
+
)
|
|
411
503
|
}
|
|
412
504
|
for (const u of req.inputs?.upstream ?? []) this.upstreamKeys.set(u.taskId, u.hash)
|
|
413
|
-
await this.share(
|
|
505
|
+
await this.share(up, nodes)
|
|
414
506
|
return new Promise((resolve, reject) => {
|
|
415
507
|
const need = this.o.need(req.taskId)
|
|
416
508
|
const job: Job = {
|
|
417
509
|
req,
|
|
510
|
+
...(nodes?.has(req.taskId) === true ? { up } : {}),
|
|
418
511
|
need: { ...need, platform: req.platform ?? this.o.home },
|
|
419
512
|
queuedAt: performance.now(),
|
|
420
513
|
resolve,
|
|
421
514
|
reject,
|
|
422
515
|
}
|
|
516
|
+
// A retry runs elsewhere: the worker, or what ran beside it, may be the flake.
|
|
517
|
+
const last = (req.attempt ?? 1) > 1 ? this.ranOn.get(req.taskId) : undefined
|
|
518
|
+
if (last !== undefined) job.avoid = last
|
|
519
|
+
this.handed.delete(req.taskId)
|
|
423
520
|
// A signal aborted already fires no `abort`.
|
|
424
521
|
if (req.signal?.aborted === true) return resolve(stopped())
|
|
425
522
|
req.signal?.addEventListener('abort', () => this.abort(job), { once: true })
|
|
426
|
-
this.
|
|
523
|
+
this.enqueue([job])
|
|
427
524
|
this.pumpSoon()
|
|
428
525
|
})
|
|
429
526
|
}
|
|
@@ -498,10 +595,21 @@ export class Orchestrator {
|
|
|
498
595
|
return this.trace.events(this.ended ? this.endedAt : performance.now())
|
|
499
596
|
}
|
|
500
597
|
|
|
598
|
+
/** No task left in the run uses server `task`: each worker that started it stops it. */
|
|
599
|
+
unneeded(task: string): void {
|
|
600
|
+
for (const a of this.workers.values()) {
|
|
601
|
+
const base = a.serving.get(task)
|
|
602
|
+
if (base === undefined) continue
|
|
603
|
+
a.serving.delete(task)
|
|
604
|
+
a.usedCores -= base
|
|
605
|
+
this.post(a, { type: 'unserve', task })
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
501
609
|
private abort(job: Job): void {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
this.queue.
|
|
610
|
+
if (this.waiting.get(job.req.taskId) === job) {
|
|
611
|
+
this.waiting.delete(job.req.taskId)
|
|
612
|
+
this.queue.delete(job.req.taskId)
|
|
505
613
|
job.resolve(stopped())
|
|
506
614
|
} else if (job.worker !== undefined) {
|
|
507
615
|
const a = this.workers.get(job.worker)
|
|
@@ -519,27 +627,36 @@ export class Orchestrator {
|
|
|
519
627
|
})
|
|
520
628
|
}
|
|
521
629
|
|
|
630
|
+
/**
|
|
631
|
+
* History changed (`need`, `tail`): the waiting book and rank by it. On
|
|
632
|
+
* CI the default branch's timings land a moment after the roots joined
|
|
633
|
+
* the queue, each booked as unmeasured and ranked as an unknown task:
|
|
634
|
+
* shards ready later went first, and test.wire waited 129 s (2026-11-07).
|
|
635
|
+
*/
|
|
636
|
+
rerank(): void {
|
|
637
|
+
for (const [id, job] of this.waiting)
|
|
638
|
+
job.need = { ...this.o.need(id), platform: job.need.platform }
|
|
639
|
+
this.queue.rerank()
|
|
640
|
+
this.pumpSoon()
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** `jobs` wait, in order, behind the waiting tasks or, `front`, before them. */
|
|
644
|
+
private enqueue(jobs: readonly Job[], front = false): void {
|
|
645
|
+
for (const j of jobs) this.waiting.set(j.req.taskId, j)
|
|
646
|
+
if (jobs.length > 0) this.waited = true
|
|
647
|
+
this.queue.add(
|
|
648
|
+
jobs.map((j) => j.req.taskId),
|
|
649
|
+
front,
|
|
650
|
+
)
|
|
651
|
+
}
|
|
652
|
+
|
|
522
653
|
private pump(): void {
|
|
523
654
|
const batches = new Map<Worker, Job[]>()
|
|
524
|
-
const left: Job[] = []
|
|
525
655
|
// Core asks in its critical-path order, but only as tasks get ready: a
|
|
526
656
|
// long chain ready a moment late queued behind 46 shards ready at once,
|
|
527
657
|
// and its 41 s build began 47 s in, the run's end (main, 2026-10-23).
|
|
528
|
-
const tail = this.o.tail
|
|
529
|
-
const how = this.o.how ?? DEFAULT_STRATEGY
|
|
530
|
-
const waiting = this.queue.splice(0)
|
|
531
658
|
const workers = [...this.workers.values()].filter((a) => !a.faulty)
|
|
532
|
-
const byId =
|
|
533
|
-
const queue =
|
|
534
|
-
tail === undefined
|
|
535
|
-
? waiting
|
|
536
|
-
: ordered(
|
|
537
|
-
waiting.map((j) => j.req.taskId),
|
|
538
|
-
how.order,
|
|
539
|
-
tail,
|
|
540
|
-
(id) => byId.get(id)!.need,
|
|
541
|
-
Math.max(1, ...workers.map((a) => a.cores)),
|
|
542
|
-
).map((id) => byId.get(id)!)
|
|
659
|
+
const byId = this.waiting
|
|
543
660
|
const bins = workers.map((a) => {
|
|
544
661
|
let running = 0
|
|
545
662
|
for (const j of a.busy.values()) if (j.held !== undefined) running++
|
|
@@ -551,45 +668,32 @@ export class Orchestrator {
|
|
|
551
668
|
running,
|
|
552
669
|
has: a.has,
|
|
553
670
|
platform: a.platform,
|
|
671
|
+
serving: new Set(a.serving.keys()),
|
|
554
672
|
a,
|
|
555
673
|
}
|
|
556
674
|
})
|
|
557
675
|
const nodes = this.o.nodes()
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
job
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if (pin === 'wait') {
|
|
572
|
-
left.push(job)
|
|
573
|
-
continue
|
|
574
|
-
}
|
|
575
|
-
placed.push(job)
|
|
576
|
-
const need = this.booking(job.need)
|
|
577
|
-
floor.cores = Math.min(floor.cores, need.cores)
|
|
578
|
-
floor.memory = Math.min(floor.memory, need.memory)
|
|
579
|
-
asks.push({ id: job.req.taskId, need, up, ...pin })
|
|
580
|
-
}
|
|
581
|
-
const to = assign(asks, bins, (above, platform) => this.bigger(above, platform), how.place, {
|
|
582
|
-
floor,
|
|
583
|
-
kinds: Infinity,
|
|
676
|
+
const to = this.queue.pass(bins, {
|
|
677
|
+
book: (need) => this.booking(need),
|
|
678
|
+
deps: (id) => (byId.get(id)!.deps ??= nodes?.get(id)?.deps),
|
|
679
|
+
gate: (id) => {
|
|
680
|
+
const job = byId.get(id)!
|
|
681
|
+
const up =
|
|
682
|
+
job.up ?? (nodes?.has(id) === true ? (job.up = upstream(nodes, id)) : upstream(nodes, id))
|
|
683
|
+
const pin = this.pin(job, up)
|
|
684
|
+
// It ended: the queue lets it go.
|
|
685
|
+
if (pin === 'done') byId.delete(id)
|
|
686
|
+
return pin
|
|
687
|
+
},
|
|
688
|
+
waits: (above, platform) => this.bigger(above, platform),
|
|
584
689
|
})
|
|
690
|
+
const placed = [...to.keys()].map((id) => byId.get(id)!)
|
|
691
|
+
for (const id of to.keys()) byId.delete(id)
|
|
585
692
|
for (const job of placed) {
|
|
586
|
-
const a = to.get(job.req.taskId)
|
|
587
|
-
if (a === undefined) {
|
|
588
|
-
left.push(job)
|
|
589
|
-
continue
|
|
590
|
-
}
|
|
693
|
+
const a = to.get(job.req.taskId)!.a
|
|
591
694
|
job.worker = a.id
|
|
592
695
|
job.sentAt = performance.now()
|
|
696
|
+
a.usage.sent(job.sentAt)
|
|
593
697
|
a.busy.set(job.req.taskId, job)
|
|
594
698
|
job.held = {
|
|
595
699
|
cores: Math.min(this.booking(job.need).cores, a.cores),
|
|
@@ -606,11 +710,16 @@ export class Orchestrator {
|
|
|
606
710
|
})
|
|
607
711
|
a.usedCores += job.held.cores
|
|
608
712
|
a.usedMemory += job.held.memory
|
|
713
|
+
// A server it starts there books its base once, while it runs.
|
|
714
|
+
for (const sv of job.need.servers ?? [])
|
|
715
|
+
if (!a.serving.has(sv.id)) {
|
|
716
|
+
a.serving.set(sv.id, sv.cores)
|
|
717
|
+
a.usedCores += sv.cores
|
|
718
|
+
}
|
|
609
719
|
const batch = batches.get(a)
|
|
610
720
|
if (batch === undefined) batches.set(a, [job])
|
|
611
721
|
else batch.push(job)
|
|
612
722
|
}
|
|
613
|
-
this.queue.push(...left)
|
|
614
723
|
for (const [a, jobs] of batches) this.send(a, jobs)
|
|
615
724
|
}
|
|
616
725
|
|
|
@@ -624,6 +733,23 @@ export class Orchestrator {
|
|
|
624
733
|
private pin(
|
|
625
734
|
job: Job,
|
|
626
735
|
up: ReadonlySet<string>,
|
|
736
|
+
): 'wait' | 'done' | { only?: string; avoid?: string } {
|
|
737
|
+
const where = this.pinUp(job, up)
|
|
738
|
+
const avoid = job.avoid
|
|
739
|
+
if (typeof where !== 'object' || avoid === undefined) return where
|
|
740
|
+
if (where.only !== undefined || where.avoid !== undefined) return where
|
|
741
|
+
// With no other worker that can take it, it runs where it failed rather than wait.
|
|
742
|
+
const platform = job.need.platform
|
|
743
|
+
for (const w of this.workers.values())
|
|
744
|
+
if (w.id !== avoid && !w.faulty && (platform === undefined || w.platform === platform))
|
|
745
|
+
return { avoid }
|
|
746
|
+
return where
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Where `job` may go as its upstreams' files lie (`pin`, before a retry's own rule). */
|
|
750
|
+
private pinUp(
|
|
751
|
+
job: Job,
|
|
752
|
+
up: ReadonlySet<string>,
|
|
627
753
|
): 'wait' | 'done' | { only?: string; avoid?: string } {
|
|
628
754
|
if (job.again !== undefined) {
|
|
629
755
|
const { only } = job.again
|
|
@@ -677,7 +803,11 @@ export class Orchestrator {
|
|
|
677
803
|
return this.o.pinWaitMs ?? PIN_WAIT_MS
|
|
678
804
|
}
|
|
679
805
|
|
|
680
|
-
/**
|
|
806
|
+
/**
|
|
807
|
+
* `ids`, uncached tasks that ran, run again where `where` says, for a
|
|
808
|
+
* dependant: on its place, which the plan refused when one names another
|
|
809
|
+
* worker type or platform (`apart`).
|
|
810
|
+
*/
|
|
681
811
|
private remake(
|
|
682
812
|
ids: readonly string[],
|
|
683
813
|
where: { only?: string; avoid?: string; platform?: string | undefined },
|
|
@@ -693,24 +823,31 @@ export class Orchestrator {
|
|
|
693
823
|
this.pumpSoon()
|
|
694
824
|
}
|
|
695
825
|
this.pumpSoon()
|
|
696
|
-
this.
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
826
|
+
this.enqueue(
|
|
827
|
+
[
|
|
828
|
+
{
|
|
829
|
+
req: k.req,
|
|
830
|
+
need: where.platform === undefined ? k.need : { ...k.need, platform: where.platform },
|
|
831
|
+
queuedAt: performance.now(),
|
|
832
|
+
pinnedAt: performance.now(),
|
|
833
|
+
again: {
|
|
834
|
+
...(where.only === undefined ? {} : { only: where.only }),
|
|
835
|
+
...(where.avoid === undefined ? {} : { avoid: where.avoid }),
|
|
836
|
+
},
|
|
837
|
+
resolve: (r) =>
|
|
838
|
+
done(
|
|
839
|
+
r.exitCode === 0
|
|
840
|
+
? undefined
|
|
841
|
+
: `${id} ran again for it and failed (exit ${r.exitCode})`,
|
|
842
|
+
),
|
|
843
|
+
reject: (err) =>
|
|
844
|
+
done(
|
|
845
|
+
`${id} ran again for it and failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
846
|
+
),
|
|
847
|
+
},
|
|
848
|
+
],
|
|
849
|
+
true,
|
|
850
|
+
)
|
|
714
851
|
if (where.only !== undefined || where.avoid !== undefined)
|
|
715
852
|
setTimeout(() => this.pumpSoon(), this.pinWaitMs + 50).unref?.()
|
|
716
853
|
}
|
|
@@ -774,7 +911,15 @@ export class Orchestrator {
|
|
|
774
911
|
if (load === undefined) return
|
|
775
912
|
a.load = { cpu: Number(load.cpu) || 0, memory: Number(load.memory) || 0, at: performance.now() }
|
|
776
913
|
const avg = Number(load.avg)
|
|
777
|
-
|
|
914
|
+
const self = Number(load.self)
|
|
915
|
+
if (Number.isFinite(avg) && avg >= 0)
|
|
916
|
+
a.usage.machine(avg, Number.isFinite(self) && self >= 0 ? self : undefined, a.load.at)
|
|
917
|
+
// Its servers' bases as it fits them (`server-fit.ts`): what the next
|
|
918
|
+
// task needing one books, and the next run.
|
|
919
|
+
if (typeof load.servers === 'object' && load.servers !== null)
|
|
920
|
+
for (const [id, cores] of Object.entries(load.servers))
|
|
921
|
+
if (typeof cores === 'number' && Number.isFinite(cores) && cores >= 0 && cores <= a.cores)
|
|
922
|
+
this.o.bases?.set(id, cores)
|
|
778
923
|
const tasks = Number(load.tasks)
|
|
779
924
|
this.trace.load(a.label, a.load.cpu, a.load.at, Number.isFinite(tasks) ? tasks : undefined)
|
|
780
925
|
}
|
|
@@ -790,7 +935,8 @@ export class Orchestrator {
|
|
|
790
935
|
!joined.has(id) &&
|
|
791
936
|
!this.unstarted.has(id) &&
|
|
792
937
|
!this.cancelled.has(id) &&
|
|
793
|
-
(platform === undefined ||
|
|
938
|
+
(platform === undefined ||
|
|
939
|
+
(declared.place ?? declared.platform ?? hostPlatform()) === platform) &&
|
|
794
940
|
(declared.cpu > above.cores || declared.memory > above.memory)
|
|
795
941
|
)
|
|
796
942
|
return true
|
|
@@ -815,11 +961,13 @@ export class Orchestrator {
|
|
|
815
961
|
* hit only this cache held (the remote evicted it, or this machine does
|
|
816
962
|
* not write it) failed the dependant there (2026-10-24).
|
|
817
963
|
*/
|
|
818
|
-
private async share(
|
|
819
|
-
|
|
964
|
+
private async share(
|
|
965
|
+
up: ReadonlySet<string>,
|
|
966
|
+
nodes: ReadonlyMap<string, TaskNode> | undefined,
|
|
967
|
+
): Promise<void> {
|
|
820
968
|
const keys: string[] = []
|
|
821
|
-
for (const d of
|
|
822
|
-
if (this.savedAs.has(d) || nodes
|
|
969
|
+
for (const d of up) {
|
|
970
|
+
if (this.savedAs.has(d) || this.dep(nodes, d) === undefined) continue
|
|
823
971
|
const key = this.upstreamKeys.get(d) ?? this.o.keyOf(d)
|
|
824
972
|
if (key !== undefined) keys.push(key)
|
|
825
973
|
}
|
|
@@ -860,92 +1008,123 @@ export class Orchestrator {
|
|
|
860
1008
|
return got.includes(true)
|
|
861
1009
|
}
|
|
862
1010
|
|
|
1011
|
+
/**
|
|
1012
|
+
* Upstream `id` as an order names it, once per graph: its project and
|
|
1013
|
+
* outputs. Undefined: it is not in the graph, or leaves no files.
|
|
1014
|
+
*/
|
|
1015
|
+
private dep(nodes: ReadonlyMap<string, TaskNode> | undefined, id: string): DepFiles | undefined {
|
|
1016
|
+
if (nodes === undefined) return undefined
|
|
1017
|
+
let of = this.deps.get(nodes)
|
|
1018
|
+
if (of === undefined) this.deps.set(nodes, (of = new Map()))
|
|
1019
|
+
let d = of.get(id)
|
|
1020
|
+
if (d === undefined) {
|
|
1021
|
+
const n = nodes.get(id)
|
|
1022
|
+
const outs = n?.config.cache === true ? n.config.outputs : undefined
|
|
1023
|
+
d =
|
|
1024
|
+
n === undefined || outs === undefined
|
|
1025
|
+
? null
|
|
1026
|
+
: {
|
|
1027
|
+
project: path.relative(this.o.root, n.projectDir),
|
|
1028
|
+
outputs: { files: [...outs.files], workspaceFiles: [...(outs.workspaceFiles ?? [])] },
|
|
1029
|
+
}
|
|
1030
|
+
of.set(id, d)
|
|
1031
|
+
}
|
|
1032
|
+
return d ?? undefined
|
|
1033
|
+
}
|
|
1034
|
+
|
|
863
1035
|
private send(a: Worker, jobs: readonly Job[]): void {
|
|
864
1036
|
const nodes = this.o.nodes()
|
|
865
|
-
const orders = jobs.map((job) => {
|
|
1037
|
+
const orders = jobs.map((job): { job: Job; order: RunOrder } => {
|
|
866
1038
|
const { req } = job
|
|
867
1039
|
const deps: DepOrder[] = []
|
|
868
1040
|
// Every one, held there or not: the worker restores each key once, and
|
|
869
1041
|
// one it was sent but gave back it never ran or restored.
|
|
870
|
-
for (const id of upstream(nodes, req.taskId)) {
|
|
871
|
-
const
|
|
872
|
-
const outs = n?.config.cache?.outputs
|
|
1042
|
+
for (const id of job.up ?? upstream(nodes, req.taskId)) {
|
|
1043
|
+
const d = this.dep(nodes, id)
|
|
873
1044
|
const key = this.savedAs.get(id) ?? this.upstreamKeys.get(id) ?? this.o.keyOf(id)
|
|
874
|
-
if (
|
|
1045
|
+
if (d === undefined || key === undefined) continue
|
|
875
1046
|
a.has.add(id)
|
|
876
|
-
deps.push({
|
|
877
|
-
task: id,
|
|
878
|
-
key,
|
|
879
|
-
project: path.relative(this.o.root, n.projectDir),
|
|
880
|
-
outputs: { files: [...outs.files], workspaceFiles: [...(outs.workspaceFiles ?? [])] },
|
|
881
|
-
})
|
|
1047
|
+
deps.push({ task: id, key, project: d.project, outputs: d.outputs })
|
|
882
1048
|
}
|
|
883
1049
|
// A task sent here leaves its outputs here for its dependants.
|
|
884
1050
|
a.has.add(req.taskId)
|
|
885
1051
|
const node = nodes?.get(req.taskId)
|
|
886
|
-
const
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1052
|
+
const keyed = hostKeyed(req)
|
|
1053
|
+
const env = defined(req.env, req.envDefine, keyed)
|
|
1054
|
+
const unset = keyed.filter((k) => req.env[k] === undefined && !(k in req.envDefine))
|
|
1055
|
+
const order = {
|
|
1056
|
+
task: req.taskId,
|
|
1057
|
+
// Unsaved: under the run's own key, as an uncached task's.
|
|
1058
|
+
...(req.cacheKey === undefined || req.unsaved === true ? {} : { key: req.cacheKey }),
|
|
1059
|
+
runKey: runKey(this.o.run, req.taskId, this.sends(req.taskId)),
|
|
1060
|
+
// A held key keeps its entry, and restored here it replaced the
|
|
1061
|
+
// forced run's fresh bytes with the ones it bypassed (2026-10-25).
|
|
1062
|
+
...(req.refresh === true ? { fresh: true as const } : {}),
|
|
1063
|
+
// A HEAD per upload was the workers' only remote probe: the run
|
|
1064
|
+
// already knows the key is in no cache (2026-10-25).
|
|
1065
|
+
...(req.refresh !== true && this.missed.has(req.taskId) ? { absent: true as const } : {}),
|
|
1066
|
+
deps,
|
|
1067
|
+
nested: node === undefined ? [] : nestedIn(nodes!, node.projectDir, this.o.root),
|
|
1068
|
+
command: withForwardArgs(req.command, req.forwardArgs),
|
|
1069
|
+
cwd: path.relative(this.o.root, req.cwd),
|
|
1070
|
+
project: node === undefined ? '' : path.relative(this.o.root, node.projectDir),
|
|
1071
|
+
env,
|
|
1072
|
+
...(unset.length === 0 ? {} : { unset }),
|
|
1073
|
+
...(node?.config.exec?.env?.secret === undefined
|
|
1074
|
+
? {}
|
|
1075
|
+
: { secret: [...node.config.exec.env.secret] }),
|
|
1076
|
+
...(req.timeoutMs === undefined ? {} : { timeoutMs: req.timeoutMs }),
|
|
1077
|
+
outputs: {
|
|
1078
|
+
files: [...req.outputs.files],
|
|
1079
|
+
workspaceFiles: [...req.outputs.workspaceFiles],
|
|
1080
|
+
},
|
|
1081
|
+
cores: job.held?.cores ?? 1,
|
|
1082
|
+
memory: job.held?.memory ?? 0,
|
|
1083
|
+
...(req.sandbox === undefined || node?.config.exec?.sandbox === undefined
|
|
1084
|
+
? {}
|
|
1085
|
+
: {
|
|
1086
|
+
sandbox: {
|
|
1087
|
+
config: node.config.exec.sandbox,
|
|
1088
|
+
...(req.sandbox.keyed === undefined
|
|
1089
|
+
? {}
|
|
1090
|
+
: { keyed: req.sandbox.keyed.map((d) => path.relative(this.o.root, d)) }),
|
|
1091
|
+
...(req.sandbox.dependencyReads === undefined
|
|
1092
|
+
? {}
|
|
1093
|
+
: {
|
|
1094
|
+
reads: req.sandbox.dependencyReads.map((d) => path.relative(this.o.root, d)),
|
|
1095
|
+
}),
|
|
1096
|
+
...(req.sandbox.dependencyReported === undefined
|
|
1097
|
+
? {}
|
|
1098
|
+
: {
|
|
1099
|
+
reported: req.sandbox.dependencyReported.map((d) =>
|
|
1100
|
+
path.relative(this.o.root, d),
|
|
1101
|
+
),
|
|
1102
|
+
}),
|
|
1103
|
+
},
|
|
1104
|
+
}),
|
|
1105
|
+
...runtimeOrders(req),
|
|
1106
|
+
...(req.servers === undefined || req.servers.length === 0
|
|
1107
|
+
? {}
|
|
1108
|
+
: {
|
|
1109
|
+
servers: req.servers.map((s) => ({
|
|
1110
|
+
task: s.taskId,
|
|
1111
|
+
command: s.command,
|
|
1112
|
+
cwd: path.relative(this.o.root, s.cwd),
|
|
1113
|
+
env: defined(s.env, {}),
|
|
1114
|
+
...(s.readyWhen === undefined ? {} : { readyWhen: s.readyWhen }),
|
|
1115
|
+
...(s.timeoutMs === undefined ? {} : { timeoutMs: s.timeoutMs }),
|
|
1116
|
+
...(s.sandbox === undefined
|
|
1117
|
+
? {}
|
|
1118
|
+
: {
|
|
1119
|
+
sandbox: {
|
|
1120
|
+
config: s.sandbox.config,
|
|
1121
|
+
nested: s.sandbox.nested.map((d) => path.relative(this.o.root, d)),
|
|
1122
|
+
},
|
|
1123
|
+
}),
|
|
1124
|
+
})),
|
|
1125
|
+
}),
|
|
1126
|
+
} as RunOrder
|
|
1127
|
+
return { job, order }
|
|
949
1128
|
})
|
|
950
1129
|
const sandboxed = orders.some(
|
|
951
1130
|
(o) => o.order.sandbox !== undefined || o.order.servers?.some((s) => s.sandbox !== undefined),
|
|
@@ -955,13 +1134,32 @@ export class Orchestrator {
|
|
|
955
1134
|
const pending = new Set(jobs)
|
|
956
1135
|
a.chain = a.chain
|
|
957
1136
|
.then(async () => {
|
|
958
|
-
|
|
1137
|
+
// A dependency the worker was sent under the same key goes by name
|
|
1138
|
+
// alone: each order named every upstream whole, and 8000 tasks of a
|
|
1139
|
+
// deep graph sent 219 MB (2026-11-05). Read here, as sends go in
|
|
1140
|
+
// order: one sent before it fails is not counted as sent.
|
|
1141
|
+
const fresh = new Map<string, string>()
|
|
1142
|
+
const wired = orders.map(({ job, order }) => {
|
|
1143
|
+
const intro: [string, string][] = []
|
|
1144
|
+
const deps = order.deps.map((d): WiredDep => {
|
|
1145
|
+
if ((fresh.get(d.task) ?? a.sentDeps.get(d.task)) === d.key) return d.task
|
|
1146
|
+
fresh.set(d.task, d.key)
|
|
1147
|
+
intro.push([d.task, d.key])
|
|
1148
|
+
return d
|
|
1149
|
+
})
|
|
1150
|
+
const sent: SentOrder = { ...order, deps }
|
|
1151
|
+
// Measured once: a giant repo's were measured three times each (2026-11-05).
|
|
1152
|
+
return { job, order: sent, intro, bytes: bytesOf(sent) }
|
|
1153
|
+
})
|
|
1154
|
+
for (const [i, part] of parts(wired, BATCH_BYTES).entries()) {
|
|
959
1155
|
// An order too long for a frame (a task of thousands of
|
|
960
1156
|
// dependencies was refused, 413) sends its deps ahead.
|
|
961
|
-
const tasks:
|
|
962
|
-
|
|
963
|
-
|
|
1157
|
+
const tasks: SentOrder[] = []
|
|
1158
|
+
let bytes = 0
|
|
1159
|
+
for (const { order, bytes: size } of part) {
|
|
1160
|
+
if (size <= BATCH_BYTES) {
|
|
964
1161
|
tasks.push(order)
|
|
1162
|
+
bytes += size
|
|
965
1163
|
continue
|
|
966
1164
|
}
|
|
967
1165
|
for (const deps of chunks(order.deps, BATCH_BYTES)) {
|
|
@@ -970,7 +1168,9 @@ export class Orchestrator {
|
|
|
970
1168
|
a.usage.messagesIn++
|
|
971
1169
|
a.usage.bytesIn += bytesOf(ahead)
|
|
972
1170
|
}
|
|
973
|
-
|
|
1171
|
+
const alone = { ...order, deps: [] }
|
|
1172
|
+
tasks.push(alone)
|
|
1173
|
+
bytes += bytesOf(alone)
|
|
974
1174
|
}
|
|
975
1175
|
const n = ++a.batches
|
|
976
1176
|
for (const { job } of part) job.batch = n
|
|
@@ -984,7 +1184,12 @@ export class Orchestrator {
|
|
|
984
1184
|
} satisfies ToWorker
|
|
985
1185
|
await this.o.me.send(a.id, batch)
|
|
986
1186
|
a.usage.messagesIn++
|
|
987
|
-
a
|
|
1187
|
+
// Its orders' bytes, a comma between each, in the batch's own.
|
|
1188
|
+
a.usage.bytesIn += bytesOf({ ...batch, tasks: [] }) + bytes + tasks.length - 1
|
|
1189
|
+
for (const { intro } of part) for (const [t, k] of intro) a.sentDeps.set(t, k)
|
|
1190
|
+
for (const { order } of part)
|
|
1191
|
+
for (const sv of order.servers ?? [])
|
|
1192
|
+
if (!a.serving.has(sv.task)) a.serving.set(sv.task, 0)
|
|
988
1193
|
for (const { job } of part) {
|
|
989
1194
|
pending.delete(job)
|
|
990
1195
|
// Sent, it runs: its worker takes only what it has room for.
|
|
@@ -1121,7 +1326,7 @@ export class Orchestrator {
|
|
|
1121
1326
|
a.started.delete(task)
|
|
1122
1327
|
const again = this.retry(a, job, `could not read ${head.unread.join(', ')}`)
|
|
1123
1328
|
if (again !== undefined) {
|
|
1124
|
-
this.
|
|
1329
|
+
this.enqueue([again], true)
|
|
1125
1330
|
this.pumpSoon()
|
|
1126
1331
|
}
|
|
1127
1332
|
return
|
|
@@ -1136,7 +1341,15 @@ export class Orchestrator {
|
|
|
1136
1341
|
a.done.push(task)
|
|
1137
1342
|
a.usage.record(head, job.sentAt)
|
|
1138
1343
|
const share = shareOf(head.cpuShare)
|
|
1139
|
-
|
|
1344
|
+
// At least as long as it ran: begun here once its batch's send resolved,
|
|
1345
|
+
// which can be after its worker began it, it was seen for less, and its
|
|
1346
|
+
// CPU over that read as more cores than it had (#5036: 1.001 each, so
|
|
1347
|
+
// two that ran side by side on 2 cores replayed one after the other).
|
|
1348
|
+
const held = Math.max(
|
|
1349
|
+
1,
|
|
1350
|
+
head.durationMs,
|
|
1351
|
+
job.startedAt === undefined ? 0 : endAt - job.startedAt,
|
|
1352
|
+
)
|
|
1140
1353
|
this.took.set(task, {
|
|
1141
1354
|
ms: held,
|
|
1142
1355
|
cores:
|
|
@@ -1162,6 +1375,7 @@ export class Orchestrator {
|
|
|
1162
1375
|
this.needMs += Math.min(job.need.cores, a.cores) * ms
|
|
1163
1376
|
}
|
|
1164
1377
|
this.savedAs.set(task, key)
|
|
1378
|
+
this.ranOn.set(task, a.id)
|
|
1165
1379
|
const node = this.o.nodes()?.get(task)
|
|
1166
1380
|
if (head.exitCode === 0 && node !== undefined && leaves(node))
|
|
1167
1381
|
this.kept.set(task, { worker: a.id, req: job.req, need: job.need })
|
|
@@ -1309,7 +1523,11 @@ export class Orchestrator {
|
|
|
1309
1523
|
if (body.type === 'joined') {
|
|
1310
1524
|
const info = body.info as Partial<WorkerInfo> | null
|
|
1311
1525
|
const id = body.id!
|
|
1526
|
+
// Only the worker heard why: the run sat silent and its footer said
|
|
1527
|
+
// none joined (2026-11-07).
|
|
1312
1528
|
const refuse = (reason: string): void => {
|
|
1529
|
+
this.refused.push(reason)
|
|
1530
|
+
this.warn(`dte: refused worker ${typeof info?.id === 'string' ? info.id : id}: ${reason}`)
|
|
1313
1531
|
void this.o.me
|
|
1314
1532
|
.send(id, { type: 'refused', reason } satisfies ToWorker)
|
|
1315
1533
|
.catch(this.unsent('refused', id))
|
|
@@ -1381,7 +1599,7 @@ export class Orchestrator {
|
|
|
1381
1599
|
cores,
|
|
1382
1600
|
memory,
|
|
1383
1601
|
reported: { cores: Number(info.cores), memory: Number(info.memory) },
|
|
1384
|
-
platform,
|
|
1602
|
+
platform: this.o.joinedPlace?.(spawn?.declared.size, platform) ?? platform,
|
|
1385
1603
|
usedCores: 0,
|
|
1386
1604
|
usedMemory: 0,
|
|
1387
1605
|
label,
|
|
@@ -1389,6 +1607,8 @@ export class Orchestrator {
|
|
|
1389
1607
|
started: new Set(),
|
|
1390
1608
|
batches: 0,
|
|
1391
1609
|
has: new Set(),
|
|
1610
|
+
serving: new Map(),
|
|
1611
|
+
sentDeps: new Map(),
|
|
1392
1612
|
// Its name first, before any batch.
|
|
1393
1613
|
chain: this.cache()
|
|
1394
1614
|
.then((cache) =>
|
|
@@ -1419,13 +1639,15 @@ export class Orchestrator {
|
|
|
1419
1639
|
a.usage.to = performance.now()
|
|
1420
1640
|
this.trace.left(a.label, 'lost', a.usage.to)
|
|
1421
1641
|
this.gone.push(a)
|
|
1642
|
+
// Its results' `end`s never come.
|
|
1643
|
+
for (const k of this.parts.keys()) if (k.startsWith(`${a.id}\0`)) this.parts.delete(k)
|
|
1422
1644
|
// Its tasks run again: first, as they were first to go.
|
|
1423
1645
|
const back: Job[] = []
|
|
1424
1646
|
for (const j of [...a.busy.values()]) {
|
|
1425
1647
|
const again = this.retry(a, j, 'lost')
|
|
1426
1648
|
if (again !== undefined) back.push(again)
|
|
1427
1649
|
}
|
|
1428
|
-
this.
|
|
1650
|
+
this.enqueue(back, true)
|
|
1429
1651
|
this.o.lost?.(a.label)
|
|
1430
1652
|
// Workers that join on their own may yet join; ones this run starts
|
|
1431
1653
|
// are all it gets.
|
|
@@ -1484,7 +1706,7 @@ export class Orchestrator {
|
|
|
1484
1706
|
if (again !== undefined) back.push(again)
|
|
1485
1707
|
}
|
|
1486
1708
|
if (back.length > 0) {
|
|
1487
|
-
this.
|
|
1709
|
+
this.enqueue(back, true)
|
|
1488
1710
|
this.o.faulted?.()
|
|
1489
1711
|
if (this.spawnedAt.size > 0)
|
|
1490
1712
|
this.stranded(`${a.label} no longer runs a task it began and no worker is left`)
|
|
@@ -1505,7 +1727,7 @@ export class Orchestrator {
|
|
|
1505
1727
|
if (j === undefined) continue
|
|
1506
1728
|
// It began it whether or not its `start` came first.
|
|
1507
1729
|
this.begun(a, m.task)
|
|
1508
|
-
if (this.retry(a, j, m.reason) !== undefined) this.
|
|
1730
|
+
if (this.retry(a, j, m.reason) !== undefined) this.enqueue([j], true)
|
|
1509
1731
|
if (a.faulty) this.o.faulted?.()
|
|
1510
1732
|
if (a.faulty && this.spawnedAt.size > 0)
|
|
1511
1733
|
this.stranded(
|
|
@@ -1554,7 +1776,7 @@ export class Orchestrator {
|
|
|
1554
1776
|
where(taskId: string): string | null | undefined {
|
|
1555
1777
|
for (const a of this.workers.values())
|
|
1556
1778
|
if (a.busy.has(taskId)) return a.started.has(taskId) ? a.label : null
|
|
1557
|
-
return this.
|
|
1779
|
+
return this.waiting.has(taskId) || this.handed.has(taskId) ? null : undefined
|
|
1558
1780
|
}
|
|
1559
1781
|
|
|
1560
1782
|
/**
|
|
@@ -1610,7 +1832,7 @@ export class Orchestrator {
|
|
|
1610
1832
|
}): void {
|
|
1611
1833
|
const work = p.work?.map(
|
|
1612
1834
|
(t) =>
|
|
1613
|
-
`${t.id} ${t.ms === undefined ? '?' : Math.round(t.ms / 100) / 10}s ${Math.round(t.cores * 100) / 100}c${t.platform === undefined ? '' : ` ${t.platform}`}`,
|
|
1835
|
+
`${t.id} ${t.ms === undefined ? '?' : Math.round(t.ms / 100) / 10}s ${Math.round(t.cores * 100) / 100}c${t.platform === undefined ? '' : ` ${bare(t.platform)}`}`,
|
|
1614
1836
|
)
|
|
1615
1837
|
const workers = p.workers.map((w) =>
|
|
1616
1838
|
w.starting === true
|
|
@@ -1716,6 +1938,7 @@ export class Orchestrator {
|
|
|
1716
1938
|
cores: a.cores,
|
|
1717
1939
|
memory: a.memory,
|
|
1718
1940
|
platform: a.platform,
|
|
1941
|
+
...(a.serving.size === 0 ? {} : { serving: new Map(a.serving) }),
|
|
1719
1942
|
...(a.declared === undefined ? {} : { size: a.declared.size }),
|
|
1720
1943
|
at: 0,
|
|
1721
1944
|
idle: a.busy.size === 0,
|
|
@@ -1727,7 +1950,8 @@ export class Orchestrator {
|
|
|
1727
1950
|
id: j.req.taskId,
|
|
1728
1951
|
cores: Math.min(a.cores, Math.max((j.held?.cores ?? 0) * over, j.need.ask?.cores ?? 0)),
|
|
1729
1952
|
memory: Math.max(j.held?.memory ?? 0, Math.min(a.memory, j.need.ask?.memory ?? 0)),
|
|
1730
|
-
|
|
1953
|
+
// Below 0: past its time, which the plan weighs (`decide`).
|
|
1954
|
+
endsIn: (ms(j.req.taskId) ?? 0) - (now - j.startedAt!),
|
|
1731
1955
|
})),
|
|
1732
1956
|
})
|
|
1733
1957
|
}
|
|
@@ -1741,7 +1965,7 @@ export class Orchestrator {
|
|
|
1741
1965
|
// A size naming none is planned as this machine's, as `dte()`
|
|
1742
1966
|
// planned it: on no platform, no task naming one fit it, and the
|
|
1743
1967
|
// next plan cancelled it.
|
|
1744
|
-
platform: declared.platform ?? hostPlatform(),
|
|
1968
|
+
platform: declared.place ?? declared.platform ?? hostPlatform(),
|
|
1745
1969
|
size: declared.size,
|
|
1746
1970
|
at: Math.max(
|
|
1747
1971
|
0,
|
|
@@ -1873,9 +2097,19 @@ export class Orchestrator {
|
|
|
1873
2097
|
|
|
1874
2098
|
/** Every task sent here and not done fails with `reason`; later ones too. */
|
|
1875
2099
|
private fail(reason: string): void {
|
|
1876
|
-
|
|
2100
|
+
const now = performance.now()
|
|
2101
|
+
for (const a of this.workers.values()) {
|
|
1877
2102
|
for (const j of a.busy.values()) j.reject(new Error(reason))
|
|
1878
|
-
|
|
2103
|
+
// Lost, keeping what it ran: dropped, the footer read it as never joined.
|
|
2104
|
+
a.usage.to = now
|
|
2105
|
+
this.trace.left(a.label, 'lost', now)
|
|
2106
|
+
this.gone.push(a)
|
|
2107
|
+
}
|
|
2108
|
+
for (const [id, j] of this.waiting) {
|
|
2109
|
+
this.queue.delete(id)
|
|
2110
|
+
j.reject(new Error(reason))
|
|
2111
|
+
}
|
|
2112
|
+
this.waiting.clear()
|
|
1879
2113
|
this.workers.clear()
|
|
1880
2114
|
this.lost = reason
|
|
1881
2115
|
}
|
|
@@ -1899,13 +2133,8 @@ export class Orchestrator {
|
|
|
1899
2133
|
* waits on its deps through those no worker ran (groups, hits).
|
|
1900
2134
|
*/
|
|
1901
2135
|
idealMs(depsOf: (id: string) => readonly string[], sizes: readonly Size[]): number | undefined {
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
const warm = new Map<string | undefined, number>()
|
|
1905
|
-
for (const a of all)
|
|
1906
|
-
if (a.warmUpMs !== undefined && !(warm.get(a.platform)! <= a.warmUpMs))
|
|
1907
|
-
warm.set(a.platform, a.warmUpMs)
|
|
1908
|
-
const least = this.warmUpMs() ?? 0
|
|
2136
|
+
const r = this.replay(depsOf)
|
|
2137
|
+
if (r === undefined) return undefined
|
|
1909
2138
|
const pool: PlanWorker[] = (
|
|
1910
2139
|
sizes.length > 0
|
|
1911
2140
|
? sizes.flatMap((s) =>
|
|
@@ -1915,14 +2144,104 @@ export class Orchestrator {
|
|
|
1915
2144
|
platform: s.platform,
|
|
1916
2145
|
})),
|
|
1917
2146
|
)
|
|
1918
|
-
:
|
|
2147
|
+
: [...this.workers.values(), ...this.gone].map((a) => ({
|
|
2148
|
+
cores: a.cores,
|
|
2149
|
+
memory: a.memory,
|
|
2150
|
+
platform: a.platform,
|
|
2151
|
+
}))
|
|
1919
2152
|
).map((w, i) => ({
|
|
1920
2153
|
...w,
|
|
1921
2154
|
id: `ideal#${i}`,
|
|
1922
|
-
at: warm
|
|
2155
|
+
at: r.warm(w.platform),
|
|
1923
2156
|
running: [],
|
|
1924
2157
|
idle: false,
|
|
1925
2158
|
}))
|
|
2159
|
+
const end = simulate(r.tasks, pool)
|
|
2160
|
+
if (!Number.isFinite(end)) return undefined
|
|
2161
|
+
return r.from + end
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
/**
|
|
2165
|
+
* The workers that would have run this run best (`advise`), from the
|
|
2166
|
+
* same replay as `idealMs`: on `sizes`, past their `max`, and of any
|
|
2167
|
+
* cores. With no sizes (workers that join on their own), the shapes
|
|
2168
|
+
* that joined stand for them, as many as joined.
|
|
2169
|
+
*/
|
|
2170
|
+
advice(depsOf: (id: string) => readonly string[], sizes: readonly Size[]): WorkerAdvice[] {
|
|
2171
|
+
// The footer and the summary each ask once the run ended: played once.
|
|
2172
|
+
if (this.ended && this.advised !== undefined) return this.advised
|
|
2173
|
+
const out = this.advise(depsOf, sizes)
|
|
2174
|
+
if (this.ended) this.advised = out
|
|
2175
|
+
return out
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
private advised: WorkerAdvice[] | undefined
|
|
2179
|
+
|
|
2180
|
+
private advise(
|
|
2181
|
+
depsOf: (id: string) => readonly string[],
|
|
2182
|
+
sizes: readonly Size[],
|
|
2183
|
+
): WorkerAdvice[] {
|
|
2184
|
+
const r = this.replay(depsOf)
|
|
2185
|
+
if (r === undefined) return []
|
|
2186
|
+
const shapes: Size[] =
|
|
2187
|
+
sizes.length > 0
|
|
2188
|
+
? [...sizes]
|
|
2189
|
+
: [
|
|
2190
|
+
...[...this.workers.values(), ...this.gone]
|
|
2191
|
+
.reduce((by, a) => {
|
|
2192
|
+
const name = `${a.cores}c/${a.platform ?? ''}`
|
|
2193
|
+
const had = by.get(name)
|
|
2194
|
+
by.set(name, {
|
|
2195
|
+
name,
|
|
2196
|
+
max: (had?.max ?? 0) + 1,
|
|
2197
|
+
cpu: a.cores,
|
|
2198
|
+
memory: a.memory,
|
|
2199
|
+
platform: a.platform,
|
|
2200
|
+
})
|
|
2201
|
+
return by
|
|
2202
|
+
}, new Map<string, Size>())
|
|
2203
|
+
.values(),
|
|
2204
|
+
]
|
|
2205
|
+
const a = advise(r.tasks, shapes, r.warm)
|
|
2206
|
+
const wire = (of: WorkerAdvice['of'], f: Fleets | undefined): WorkerAdvice[] =>
|
|
2207
|
+
f === undefined
|
|
2208
|
+
? []
|
|
2209
|
+
: [
|
|
2210
|
+
{
|
|
2211
|
+
of,
|
|
2212
|
+
endMs: Math.round(r.from + f.endMs),
|
|
2213
|
+
workers: f.fleet.map((w) => ({
|
|
2214
|
+
count: w.count,
|
|
2215
|
+
cores: w.cores,
|
|
2216
|
+
memoryBytes: w.memory,
|
|
2217
|
+
...(w.platform === undefined ? {} : { platform: bare(w.platform) }),
|
|
2218
|
+
...(w.size === undefined || sizes.length === 0 ? {} : { size: w.size }),
|
|
2219
|
+
})),
|
|
2220
|
+
},
|
|
2221
|
+
]
|
|
2222
|
+
return [...wire('declared', a.declared), ...wire('more', a.more), ...wire('any', a.any)]
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
/**
|
|
2226
|
+
* Each task a worker ran, as long as it held its worker and with the
|
|
2227
|
+
* cores it used meanwhile, waiting on its deps through those no worker
|
|
2228
|
+
* ran (groups, hits); each platform's fastest real warm-up; and when
|
|
2229
|
+
* the run first knew its work.
|
|
2230
|
+
*/
|
|
2231
|
+
private replay(depsOf: (id: string) => readonly string[]):
|
|
2232
|
+
| {
|
|
2233
|
+
tasks: PlanTask[]
|
|
2234
|
+
warm: (platform: string | undefined) => number
|
|
2235
|
+
from: number
|
|
2236
|
+
}
|
|
2237
|
+
| undefined {
|
|
2238
|
+
if (this.took.size === 0) return undefined
|
|
2239
|
+
const all = [...this.workers.values(), ...this.gone]
|
|
2240
|
+
const warm = new Map<string | undefined, number>()
|
|
2241
|
+
for (const a of all)
|
|
2242
|
+
if (a.warmUpMs !== undefined && !(warm.get(a.platform)! <= a.warmUpMs))
|
|
2243
|
+
warm.set(a.platform, a.warmUpMs)
|
|
2244
|
+
const least = this.warmUpMs() ?? 0
|
|
1926
2245
|
const seen = new Map<string, string[]>()
|
|
1927
2246
|
const ran = (id: string): string[] => {
|
|
1928
2247
|
const known = seen.get(id)
|
|
@@ -1932,10 +2251,11 @@ export class Orchestrator {
|
|
|
1932
2251
|
seen.set(id, out)
|
|
1933
2252
|
return out
|
|
1934
2253
|
}
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
2254
|
+
return {
|
|
2255
|
+
tasks: [...this.took].map(([id, t]) => ({ id, ...t, deps: ran(id) })),
|
|
2256
|
+
warm: (platform) => warm.get(platform) ?? least,
|
|
2257
|
+
from: this.probe?.at ?? this.asks[0]?.at ?? 0,
|
|
2258
|
+
}
|
|
1939
2259
|
}
|
|
1940
2260
|
|
|
1941
2261
|
/**
|
|
@@ -1994,11 +2314,30 @@ export class Orchestrator {
|
|
|
1994
2314
|
* each, the cores and memory its size declares and its machine reports;
|
|
1995
2315
|
* and `ideal`, the run's `idealMs`, asked once it ended.
|
|
1996
2316
|
*/
|
|
1997
|
-
footer(
|
|
2317
|
+
footer(
|
|
2318
|
+
ideal?: () => number | undefined,
|
|
2319
|
+
advice?: () => readonly WorkerAdvice[],
|
|
2320
|
+
): [string, ui.Inline][] {
|
|
1998
2321
|
// Over with none, it says so: core's own `N workers` (this machine's
|
|
1999
|
-
// pool) read as the run's when no task needed one (2026-10-25)
|
|
2322
|
+
// pool) read as the run's when no task needed one (2026-10-25), and
|
|
2323
|
+
// says when one did and none joined (2026-11-06), and why any it
|
|
2324
|
+
// refused was (2026-11-07).
|
|
2000
2325
|
if (this.spawnedAt.size === 0 && this.workers.size === 0 && this.gone.length === 0)
|
|
2001
|
-
return this.ended
|
|
2326
|
+
return this.ended
|
|
2327
|
+
? [
|
|
2328
|
+
[
|
|
2329
|
+
'workers',
|
|
2330
|
+
this.refused.length > 0
|
|
2331
|
+
? ui.tone(
|
|
2332
|
+
'warn',
|
|
2333
|
+
`none joined: ${this.refused.length} refused (${this.refused[0]})`,
|
|
2334
|
+
)
|
|
2335
|
+
: this.waited
|
|
2336
|
+
? ui.tone('warn', 'none joined: a task waited on one')
|
|
2337
|
+
: ui.dim('none started: no task needed one'),
|
|
2338
|
+
],
|
|
2339
|
+
]
|
|
2340
|
+
: []
|
|
2002
2341
|
const gone = new Set(this.gone)
|
|
2003
2342
|
const all = [...this.workers.values(), ...this.gone]
|
|
2004
2343
|
const joined = new Set(all.map((a) => a.label))
|
|
@@ -2068,13 +2407,18 @@ export class Orchestrator {
|
|
|
2068
2407
|
// Once over, what each did, under its line.
|
|
2069
2408
|
const usage = new Map<string, readonly string[]>()
|
|
2070
2409
|
let cpuMs = 0
|
|
2410
|
+
let tasksMs = 0
|
|
2411
|
+
let workerMs: number | undefined
|
|
2071
2412
|
let coreMs = 0
|
|
2072
2413
|
if (this.ended)
|
|
2073
2414
|
for (const a of all) {
|
|
2074
2415
|
resources(a.label, a.reported.cores, a.reported.memory, a.declared)
|
|
2075
2416
|
const st = a.usage.stats(a.cores, this.endedAt)
|
|
2076
|
-
cpuMs += st.
|
|
2077
|
-
|
|
2417
|
+
cpuMs += st.workCoreMs
|
|
2418
|
+
tasksMs += st.tasksUtilization * a.cores * st.workMs
|
|
2419
|
+
if (st.workerUtilization !== undefined)
|
|
2420
|
+
workerMs = (workerMs ?? 0) + st.workerUtilization * a.cores * st.workMs
|
|
2421
|
+
coreMs += a.cores * st.workMs
|
|
2078
2422
|
usage.set(a.label, describe(st, a.usage))
|
|
2079
2423
|
}
|
|
2080
2424
|
return [
|
|
@@ -2125,6 +2469,17 @@ export class Orchestrator {
|
|
|
2125
2469
|
),
|
|
2126
2470
|
] as [string, ui.Inline],
|
|
2127
2471
|
])(ideal?.()),
|
|
2472
|
+
// What to declare next time (2026-11-05: "So I can add more
|
|
2473
|
+
// or change their specs").
|
|
2474
|
+
...(advice?.() ?? []).map(
|
|
2475
|
+
(a) =>
|
|
2476
|
+
[
|
|
2477
|
+
'',
|
|
2478
|
+
ui.dim(
|
|
2479
|
+
`${ADVICE[a.of][0].padEnd(width)} ${a.workers.map(fleetText).join(' + ')}: ${seconds(a.endMs)}, ${ADVICE[a.of][1]}`,
|
|
2480
|
+
),
|
|
2481
|
+
] as [string, ui.Inline],
|
|
2482
|
+
),
|
|
2128
2483
|
...(this.unmeasured.length === 0
|
|
2129
2484
|
? []
|
|
2130
2485
|
: [
|
|
@@ -2141,7 +2496,7 @@ export class Orchestrator {
|
|
|
2141
2496
|
? [
|
|
2142
2497
|
[
|
|
2143
2498
|
'',
|
|
2144
|
-
`${'total'.padEnd(width)}
|
|
2499
|
+
`${'total'.padEnd(width)} ${utilization(cpuMs / coreMs, tasksMs / coreMs, workerMs === undefined ? undefined : workerMs / coreMs)}${this.needMs < LEARN_MS ? '' : ` · tasks used ${percent(this.usedMs / this.needMs)} of their history`}`,
|
|
2145
2500
|
] as [string, ui.Inline],
|
|
2146
2501
|
]
|
|
2147
2502
|
: []),
|
|
@@ -2190,15 +2545,28 @@ export function nestedIn(
|
|
|
2190
2545
|
): string[] {
|
|
2191
2546
|
let of = nested.get(nodes)
|
|
2192
2547
|
if (of === undefined) {
|
|
2193
|
-
of = {
|
|
2548
|
+
of = {
|
|
2549
|
+
dirs: [...new Set([...nodes.values()].map((n) => n.projectDir))].sort(),
|
|
2550
|
+
by: new Map(),
|
|
2551
|
+
}
|
|
2194
2552
|
nested.set(nodes, of)
|
|
2195
2553
|
}
|
|
2196
2554
|
let out = of.by.get(dir)
|
|
2197
2555
|
if (out === undefined) {
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2556
|
+
// Sorted, the ones under `dir` lie together: each project read every
|
|
2557
|
+
// project, 16 million reads for 4000 (2026-11-05).
|
|
2558
|
+
const under = dir + path.sep
|
|
2559
|
+
let lo = 0
|
|
2560
|
+
let hi = of.dirs.length
|
|
2561
|
+
while (lo < hi) {
|
|
2562
|
+
const mid = (lo + hi) >> 1
|
|
2563
|
+
if (of.dirs[mid]! < under) lo = mid + 1
|
|
2564
|
+
else hi = mid
|
|
2565
|
+
}
|
|
2566
|
+
const found: string[] = []
|
|
2567
|
+
for (let i = lo; i < of.dirs.length && of.dirs[i]!.startsWith(under); i++)
|
|
2568
|
+
found.push(path.relative(root, of.dirs[i]!))
|
|
2569
|
+
out = found.sort()
|
|
2202
2570
|
of.by.set(dir, out)
|
|
2203
2571
|
}
|
|
2204
2572
|
return [...out]
|
|
@@ -2208,27 +2576,49 @@ const nested = new WeakMap<
|
|
|
2208
2576
|
{ dirs: string[]; by: Map<string, readonly string[]> }
|
|
2209
2577
|
>()
|
|
2210
2578
|
|
|
2211
|
-
/** A tree's
|
|
2212
|
-
type Tree = { files:
|
|
2579
|
+
/** A tree's files, each path with its identity as core keys it, and its submodules' (gitlinks); `dirs` once asked. */
|
|
2580
|
+
type Tree = { files: Map<string, string>; links: string[]; dirs?: Set<string> }
|
|
2581
|
+
|
|
2582
|
+
/** Whether `tree` has a file under `dir`, or `dir` in a submodule (checked out or not, its own say). */
|
|
2583
|
+
function holds(tree: Tree, dir: string): boolean {
|
|
2584
|
+
if (tree.dirs === undefined) {
|
|
2585
|
+
const dirs = (tree.dirs = new Set<string>())
|
|
2586
|
+
const above = (f: string): void => {
|
|
2587
|
+
for (let d = path.posix.dirname(f); d !== '.' && !dirs.has(d); d = path.posix.dirname(d))
|
|
2588
|
+
dirs.add(d)
|
|
2589
|
+
}
|
|
2590
|
+
for (const f of tree.files.keys()) above(f)
|
|
2591
|
+
for (const l of tree.links) above(l)
|
|
2592
|
+
}
|
|
2593
|
+
return tree.dirs.has(dir) || tree.links.some((l) => dir === l || dir.startsWith(`${l}/`))
|
|
2594
|
+
}
|
|
2213
2595
|
|
|
2214
2596
|
/** `tree`'s files and submodules under `root`, relative to it; undefined when git cannot list it. */
|
|
2215
2597
|
async function listTree(root: string, tree: string): Promise<Tree | undefined> {
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2598
|
+
let p
|
|
2599
|
+
try {
|
|
2600
|
+
p = Bun.spawn(['git', 'ls-tree', '-r', '-z', tree], {
|
|
2601
|
+
cwd: root,
|
|
2602
|
+
stdin: 'ignore',
|
|
2603
|
+
stdout: 'pipe',
|
|
2604
|
+
stderr: 'ignore',
|
|
2605
|
+
})
|
|
2606
|
+
} catch {
|
|
2607
|
+
// quiet: no git, or no root, is git unable to say.
|
|
2608
|
+
return undefined
|
|
2609
|
+
}
|
|
2222
2610
|
const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited])
|
|
2223
2611
|
if (code !== 0) return undefined
|
|
2224
|
-
const files = new
|
|
2612
|
+
const files = new Map<string, string>()
|
|
2225
2613
|
const links: string[] = []
|
|
2226
2614
|
for (const e of out.split('\0')) {
|
|
2227
2615
|
const tab = e.indexOf('\t')
|
|
2228
2616
|
if (tab === -1) continue
|
|
2229
2617
|
const name = e.slice(tab + 1)
|
|
2230
|
-
|
|
2231
|
-
|
|
2618
|
+
// `<mode> <type> <oid>`: core's identity is the OID, the mode before it unless a plain file's.
|
|
2619
|
+
const [mode, , oid] = e.slice(0, tab).split(' ')
|
|
2620
|
+
if (mode === '160000') links.push(name)
|
|
2621
|
+
else files.set(name, mode === '100644' ? oid! : `${mode}:${oid}`)
|
|
2232
2622
|
}
|
|
2233
2623
|
return { files, links }
|
|
2234
2624
|
}
|
|
@@ -2250,11 +2640,52 @@ function sandboxConfigs(nodes: ReadonlyMap<string, TaskNode> | undefined): Sandb
|
|
|
2250
2640
|
* pinning every dependant to its worker held a run's shards on one.
|
|
2251
2641
|
*/
|
|
2252
2642
|
function leaves(node: TaskNode): boolean {
|
|
2253
|
-
if (node.config.cache
|
|
2643
|
+
if (node.config.cache === true) return false
|
|
2254
2644
|
const sandbox = node.config.exec?.sandbox
|
|
2255
2645
|
return sandbox === undefined || (sandbox.allow?.write?.length ?? 0) > 0
|
|
2256
2646
|
}
|
|
2257
2647
|
|
|
2648
|
+
/**
|
|
2649
|
+
* A task and an upstream of it that `leaves` files, which `bound` says
|
|
2650
|
+
* cannot run where the task does (its `exec.worker` or `exec.platforms`):
|
|
2651
|
+
* the task reads those files where the upstream ran, or runs it again
|
|
2652
|
+
* where it runs itself. Undefined when none.
|
|
2653
|
+
*/
|
|
2654
|
+
export function apart(
|
|
2655
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
2656
|
+
bound: (upstream: TaskNode, dependant: TaskNode) => boolean,
|
|
2657
|
+
): { upstream: string; dependant: string } | undefined {
|
|
2658
|
+
const runs = (n: TaskNode): boolean =>
|
|
2659
|
+
n.config.exec?.command !== undefined && n.config.exec.persistent === undefined
|
|
2660
|
+
const named = [...nodes.values()].filter(
|
|
2661
|
+
(n) =>
|
|
2662
|
+
runs(n) &&
|
|
2663
|
+
leaves(n) &&
|
|
2664
|
+
(n.config.exec?.worker !== undefined || n.config.exec?.platforms !== undefined),
|
|
2665
|
+
)
|
|
2666
|
+
if (named.length === 0) return undefined
|
|
2667
|
+
const below = new Map<string, string[]>()
|
|
2668
|
+
for (const n of nodes.values())
|
|
2669
|
+
for (const d of n.deps) {
|
|
2670
|
+
const of = below.get(d)
|
|
2671
|
+
if (of === undefined) below.set(d, [n.id])
|
|
2672
|
+
else of.push(n.id)
|
|
2673
|
+
}
|
|
2674
|
+
for (const u of named) {
|
|
2675
|
+
const seen = new Set<string>()
|
|
2676
|
+
const walk = [...(below.get(u.id) ?? [])]
|
|
2677
|
+
for (let id = walk.pop(); id !== undefined; id = walk.pop()) {
|
|
2678
|
+
if (seen.has(id)) continue
|
|
2679
|
+
seen.add(id)
|
|
2680
|
+
const d = nodes.get(id)
|
|
2681
|
+
if (d === undefined) continue
|
|
2682
|
+
if (runs(d) && bound(u, d)) return { upstream: u.id, dependant: id }
|
|
2683
|
+
walk.push(...(below.get(id) ?? []))
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
return undefined
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2258
2689
|
/**
|
|
2259
2690
|
* Every task `id` depends on, transitively, each after its own
|
|
2260
2691
|
* dependencies: the order a worker restores them in. Dependant first, an
|
|
@@ -2276,11 +2707,17 @@ function upstream(nodes: ReadonlyMap<string, TaskNode> | undefined, id: string):
|
|
|
2276
2707
|
return out
|
|
2277
2708
|
}
|
|
2278
2709
|
|
|
2710
|
+
/** Core-time busy over core-time had, from first send to last end: its machine, its tasks, its worker. */
|
|
2711
|
+
function utilization(machine: number, tasks: number, worker: number | undefined): string {
|
|
2712
|
+
return `cpu utilization machine ${percent(machine)} · tasks ${percent(tasks)}${worker === undefined ? '' : ` · worker ${percent(worker)}`}`
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2279
2715
|
/** A worker's run, spelled out: time, CPU, memory and the relay. */
|
|
2280
2716
|
function describe(st: UsageStats, u: Usage): string[] {
|
|
2281
2717
|
return [
|
|
2282
2718
|
`online ${seconds(st.onlineMs)} · busy ${seconds(st.busyMs)} · idle ${seconds(Math.max(0, st.onlineMs - st.busyMs))}`,
|
|
2283
|
-
`cpu peak ${st.cpuPeak.toFixed(1)} · used avg ${st.cpuAvg.toFixed(1)} cores${st.machineAvg === undefined ? '' : ` · machine avg ${st.machineAvg.toFixed(1)}`} · booked avg ${st.bookedAvg.toFixed(1)}
|
|
2719
|
+
`cpu peak ${st.cpuPeak.toFixed(1)} · used avg ${st.cpuAvg.toFixed(1)} cores${st.machineAvg === undefined ? '' : ` · machine avg ${st.machineAvg.toFixed(1)}`} · booked avg ${st.bookedAvg.toFixed(1)}`,
|
|
2720
|
+
utilization(st.utilization, st.tasksUtilization, st.workerUtilization),
|
|
2284
2721
|
// No task said what it held: `memory peak 0 B` would be a claim.
|
|
2285
2722
|
...(st.memoryPeak > 0
|
|
2286
2723
|
? [`memory peak ${bytes(st.memoryPeak)} · avg ${bytes(st.memoryAvg)}`]
|
|
@@ -2330,16 +2767,24 @@ function runtimeOrders(req: ExecuteRequest): { runtime?: RuntimeOrder[] } {
|
|
|
2330
2767
|
|
|
2331
2768
|
/**
|
|
2332
2769
|
* `env` as it travels: no unset name, and no name whose value is the
|
|
2333
|
-
* worker's (`HOST_ENV`) unless `define` sets it
|
|
2334
|
-
* with the worker's own, saved under a key
|
|
2335
|
-
* (2026-10-24)
|
|
2770
|
+
* worker's (`HOST_ENV`) unless `define` sets it or the task keys it
|
|
2771
|
+
* (`keyed`). A defined `HOME` ran with the worker's own, saved under a key
|
|
2772
|
+
* that folded the defined one (2026-10-24); a keyed `XDG_CONFIG_HOME` did
|
|
2773
|
+
* the same (2026-11-07).
|
|
2336
2774
|
*/
|
|
2337
2775
|
function defined(
|
|
2338
2776
|
env: NodeJS.ProcessEnv,
|
|
2339
2777
|
define: Readonly<Record<string, string>>,
|
|
2778
|
+
keyed: readonly string[] = [],
|
|
2340
2779
|
): Record<string, string> {
|
|
2341
|
-
|
|
2780
|
+
// No prototype: on `{}`, a name `__proto__` hit the setter and was not sent.
|
|
2781
|
+
const out: Record<string, string> = Object.create(null)
|
|
2342
2782
|
for (const [k, v] of Object.entries(env))
|
|
2343
|
-
if (v !== undefined && (!HOST_ENV.includes(k) || k in define)) out[k] = v
|
|
2783
|
+
if (v !== undefined && (!HOST_ENV.includes(k) || k in define || keyed.includes(k))) out[k] = v
|
|
2344
2784
|
return out
|
|
2345
2785
|
}
|
|
2786
|
+
|
|
2787
|
+
/** The `HOST_ENV` names the task's `inputs.env` keys: their value is this machine's, as keyed. */
|
|
2788
|
+
function hostKeyed(req: ExecuteRequest): string[] {
|
|
2789
|
+
return (req.inputs?.env ?? []).map((e) => e.name).filter((k) => HOST_ENV.includes(k))
|
|
2790
|
+
}
|