@vzn/vx-dte 0.0.651 → 0.0.652
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 +33 -2
- package/package.json +3 -3
- package/src/dispatch.ts +501 -26
- package/src/forecast.ts +1 -2
- package/src/messages.ts +14 -4
- package/src/orchestrator.ts +415 -202
- package/src/planner.ts +1322 -172
- package/src/plugin.ts +33 -3
- package/src/wire.ts +36 -8
- package/src/worker.ts +26 -4
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'
|
|
@@ -84,8 +95,15 @@ interface Job {
|
|
|
84
95
|
reread?: Set<string>
|
|
85
96
|
/** 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
97
|
up?: Set<string>
|
|
98
|
+
/** Its direct dependencies, as placing reads them (`Ask.up`). */
|
|
99
|
+
deps?: readonly string[] | undefined
|
|
100
|
+
/** A retry: the worker its last attempt ran on, which it goes elsewhere than while another can take it. */
|
|
101
|
+
avoid?: string
|
|
87
102
|
}
|
|
88
103
|
|
|
104
|
+
/** An upstream's files, as an order names them (`DepOrder`). */
|
|
105
|
+
type DepFiles = Pick<DepOrder, 'project' | 'outputs'>
|
|
106
|
+
|
|
89
107
|
/** 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
108
|
const PIN_WAIT_MS = 10_000
|
|
91
109
|
|
|
@@ -123,6 +141,8 @@ interface Worker {
|
|
|
123
141
|
load?: { cpu: number; memory: number; at: number }
|
|
124
142
|
/** Tasks whose outputs it holds: it ran them, or was sent them. */
|
|
125
143
|
readonly has: Set<string>
|
|
144
|
+
/** Each dependency it was sent whole, by the key it was sent under: the next order names it alone. */
|
|
145
|
+
readonly sentDeps: Map<string, string>
|
|
126
146
|
/** Sends to it go out in order: a run's parts before the run. */
|
|
127
147
|
chain: Promise<void>
|
|
128
148
|
/** It was sent the run's sandbox configs. */
|
|
@@ -207,6 +227,15 @@ const BATCH_BYTES = 512 * 1024
|
|
|
207
227
|
const FEW = 2
|
|
208
228
|
/** The unmeasured tasks the summary names; the rest it counts. */
|
|
209
229
|
const NAMED = 40
|
|
230
|
+
/** The footer's label of each `WorkerAdvice`. */
|
|
231
|
+
const ADVICE: Record<WorkerAdvice['of'], readonly [label: string, note: string]> = {
|
|
232
|
+
declared: ['best', 'the fewest of its sizes'],
|
|
233
|
+
more: ['more', "past its sizes' max"],
|
|
234
|
+
any: ['any', 'of any cores, the least core time'],
|
|
235
|
+
}
|
|
236
|
+
/** `3 × small (4c/16G linux)`. */
|
|
237
|
+
const fleetText = (w: WorkerAdvice['workers'][number]): string =>
|
|
238
|
+
`${w.count} × ${w.size === undefined ? '' : `${w.size} `}(${w.cores}c/${compact(w.memoryBytes)}${w.platform === undefined ? '' : ` ${w.platform}`})`
|
|
210
239
|
/** Core-time of history the run's tasks finish before their use reshapes bookings: a few tasks' worth. */
|
|
211
240
|
const LEARN_MS = 60_000
|
|
212
241
|
/** The most a worker's bookings may add up to, in its cores, when tasks use less than their history. */
|
|
@@ -215,12 +244,12 @@ const OVERBOOK = 1.5
|
|
|
215
244
|
const RESTARTS = 3
|
|
216
245
|
|
|
217
246
|
/** `orders` in runs of at most `max` bytes of JSON; one bigger goes alone. */
|
|
218
|
-
function parts<T extends {
|
|
247
|
+
function parts<T extends { bytes: number }>(orders: readonly T[], max: number): T[][] {
|
|
219
248
|
const out: T[][] = []
|
|
220
249
|
let part: T[] = []
|
|
221
250
|
let bytes = 0
|
|
222
251
|
for (const o of orders) {
|
|
223
|
-
const size =
|
|
252
|
+
const size = o.bytes
|
|
224
253
|
if (part.length > 0 && bytes + size > max) {
|
|
225
254
|
out.push(part)
|
|
226
255
|
part = []
|
|
@@ -259,9 +288,13 @@ export class Orchestrator {
|
|
|
259
288
|
private readonly workers = new Map<string, Worker>()
|
|
260
289
|
/** The run tree's files, workspace-relative, listed when a task first asks. */
|
|
261
290
|
private inTree?: Promise<Tree | undefined>
|
|
262
|
-
|
|
291
|
+
/** Waiting tasks, by id, in the order a pump reads them (`Queue`). */
|
|
292
|
+
private readonly waiting = new Map<string, Job>()
|
|
293
|
+
private readonly queue: Queue
|
|
263
294
|
/** Where a worker saved each task it ran: its cache key or its run key. */
|
|
264
295
|
private readonly savedAs = new Map<string, string>()
|
|
296
|
+
/** The worker each task last ran on, which its retry goes elsewhere than. */
|
|
297
|
+
private readonly ranOn = new Map<string, string>()
|
|
265
298
|
/** Keys the run's requests named for their dependencies. */
|
|
266
299
|
private readonly upstreamKeys = new Map<string, string>()
|
|
267
300
|
/** How many times each task was sent: its run key is one per send. */
|
|
@@ -354,9 +387,12 @@ export class Orchestrator {
|
|
|
354
387
|
private relend: ReturnType<typeof setTimeout> | undefined
|
|
355
388
|
/** The plugins any of the run's lends lent a token. */
|
|
356
389
|
private readonly lenders = new Set<string>()
|
|
390
|
+
/** Each graph's upstreams as orders name them (`dep`). */
|
|
391
|
+
private readonly deps = new WeakMap<ReadonlyMap<string, TaskNode>, Map<string, DepFiles | null>>()
|
|
357
392
|
|
|
358
393
|
constructor(private readonly o: OrchestratorOptions) {
|
|
359
394
|
this.sent = o.sent ?? new Map()
|
|
395
|
+
this.queue = new Queue(o.how ?? DEFAULT_STRATEGY, o.tail, (id) => this.waiting.get(id)!.need)
|
|
360
396
|
// A root that is gone fails the run's own reads first.
|
|
361
397
|
this.realRoot = (() => {
|
|
362
398
|
try {
|
|
@@ -371,6 +407,11 @@ export class Orchestrator {
|
|
|
371
407
|
|
|
372
408
|
async execute(req: ExecuteRequest): Promise<ExecuteResult> {
|
|
373
409
|
if (this.lost !== undefined) throw new Error(this.lost)
|
|
410
|
+
// Walked once here, for every later read: walked again on the way in,
|
|
411
|
+
// sharing, placing and sending, a run of 8000 tasks spent 17 s of this
|
|
412
|
+
// machine's CPU on it (2026-11-05).
|
|
413
|
+
const nodes = this.o.nodes()
|
|
414
|
+
const up = upstream(nodes, req.taskId)
|
|
374
415
|
// A worker runs the run's tree: a file its key includes that the tree
|
|
375
416
|
// lacks (untracked) is not there, and the output it saved under that
|
|
376
417
|
// key was a stale hit for the next run (2026-10-24).
|
|
@@ -380,25 +421,16 @@ export class Orchestrator {
|
|
|
380
421
|
// A dependency's output is restored there before the task runs: a
|
|
381
422
|
// key that reads an upstream's bytes (`inputs.tasks` leaving it out)
|
|
382
423
|
// 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
|
-
})
|
|
424
|
+
let restored: ((file: string) => boolean)[] | undefined
|
|
425
|
+
const made = (file: string): boolean =>
|
|
426
|
+
(restored ??= [...up].flatMap((id) => {
|
|
427
|
+
const d = this.dep(nodes, id)
|
|
428
|
+
return d === undefined ? [] : [names(d.project, d.outputs)]
|
|
429
|
+
})).some((m) => m(file))
|
|
396
430
|
const stray =
|
|
397
431
|
tree === undefined
|
|
398
432
|
? undefined
|
|
399
|
-
: req.inputs.files.find(
|
|
400
|
-
(f) => !tree.files.has(f.path) && !restored.some((made) => made(f.path)),
|
|
401
|
-
)
|
|
433
|
+
: req.inputs.files.find((f) => !tree.files.has(f.path) && !made(f.path))
|
|
402
434
|
if (stray !== undefined) {
|
|
403
435
|
// A committed submodule's files are not the tree's: its gitlink is.
|
|
404
436
|
const link = tree?.links.find((l) => stray.path.startsWith(`${l}/`))
|
|
@@ -410,20 +442,24 @@ export class Orchestrator {
|
|
|
410
442
|
}
|
|
411
443
|
}
|
|
412
444
|
for (const u of req.inputs?.upstream ?? []) this.upstreamKeys.set(u.taskId, u.hash)
|
|
413
|
-
await this.share(
|
|
445
|
+
await this.share(up, nodes)
|
|
414
446
|
return new Promise((resolve, reject) => {
|
|
415
447
|
const need = this.o.need(req.taskId)
|
|
416
448
|
const job: Job = {
|
|
417
449
|
req,
|
|
450
|
+
...(nodes?.has(req.taskId) === true ? { up } : {}),
|
|
418
451
|
need: { ...need, platform: req.platform ?? this.o.home },
|
|
419
452
|
queuedAt: performance.now(),
|
|
420
453
|
resolve,
|
|
421
454
|
reject,
|
|
422
455
|
}
|
|
456
|
+
// A retry runs elsewhere: the worker, or what ran beside it, may be the flake.
|
|
457
|
+
const last = (req.attempt ?? 1) > 1 ? this.ranOn.get(req.taskId) : undefined
|
|
458
|
+
if (last !== undefined) job.avoid = last
|
|
423
459
|
// A signal aborted already fires no `abort`.
|
|
424
460
|
if (req.signal?.aborted === true) return resolve(stopped())
|
|
425
461
|
req.signal?.addEventListener('abort', () => this.abort(job), { once: true })
|
|
426
|
-
this.
|
|
462
|
+
this.enqueue([job])
|
|
427
463
|
this.pumpSoon()
|
|
428
464
|
})
|
|
429
465
|
}
|
|
@@ -499,9 +535,9 @@ export class Orchestrator {
|
|
|
499
535
|
}
|
|
500
536
|
|
|
501
537
|
private abort(job: Job): void {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
this.queue.
|
|
538
|
+
if (this.waiting.get(job.req.taskId) === job) {
|
|
539
|
+
this.waiting.delete(job.req.taskId)
|
|
540
|
+
this.queue.delete(job.req.taskId)
|
|
505
541
|
job.resolve(stopped())
|
|
506
542
|
} else if (job.worker !== undefined) {
|
|
507
543
|
const a = this.workers.get(job.worker)
|
|
@@ -519,27 +555,22 @@ export class Orchestrator {
|
|
|
519
555
|
})
|
|
520
556
|
}
|
|
521
557
|
|
|
558
|
+
/** `jobs` wait, in order, behind the waiting tasks or, `front`, before them. */
|
|
559
|
+
private enqueue(jobs: readonly Job[], front = false): void {
|
|
560
|
+
for (const j of jobs) this.waiting.set(j.req.taskId, j)
|
|
561
|
+
this.queue.add(
|
|
562
|
+
jobs.map((j) => j.req.taskId),
|
|
563
|
+
front,
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
|
|
522
567
|
private pump(): void {
|
|
523
568
|
const batches = new Map<Worker, Job[]>()
|
|
524
|
-
const left: Job[] = []
|
|
525
569
|
// Core asks in its critical-path order, but only as tasks get ready: a
|
|
526
570
|
// long chain ready a moment late queued behind 46 shards ready at once,
|
|
527
571
|
// 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
572
|
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)!)
|
|
573
|
+
const byId = this.waiting
|
|
543
574
|
const bins = workers.map((a) => {
|
|
544
575
|
let running = 0
|
|
545
576
|
for (const j of a.busy.values()) if (j.held !== undefined) running++
|
|
@@ -555,39 +586,24 @@ export class Orchestrator {
|
|
|
555
586
|
}
|
|
556
587
|
})
|
|
557
588
|
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,
|
|
589
|
+
const to = this.queue.pass(bins, {
|
|
590
|
+
book: (need) => this.booking(need),
|
|
591
|
+
deps: (id) => (byId.get(id)!.deps ??= nodes?.get(id)?.deps),
|
|
592
|
+
gate: (id) => {
|
|
593
|
+
const job = byId.get(id)!
|
|
594
|
+
const up =
|
|
595
|
+
job.up ?? (nodes?.has(id) === true ? (job.up = upstream(nodes, id)) : upstream(nodes, id))
|
|
596
|
+
const pin = this.pin(job, up)
|
|
597
|
+
// It ended: the queue lets it go.
|
|
598
|
+
if (pin === 'done') byId.delete(id)
|
|
599
|
+
return pin
|
|
600
|
+
},
|
|
601
|
+
waits: (above, platform) => this.bigger(above, platform),
|
|
584
602
|
})
|
|
603
|
+
const placed = [...to.keys()].map((id) => byId.get(id)!)
|
|
604
|
+
for (const id of to.keys()) byId.delete(id)
|
|
585
605
|
for (const job of placed) {
|
|
586
|
-
const a = to.get(job.req.taskId)
|
|
587
|
-
if (a === undefined) {
|
|
588
|
-
left.push(job)
|
|
589
|
-
continue
|
|
590
|
-
}
|
|
606
|
+
const a = to.get(job.req.taskId)!.a
|
|
591
607
|
job.worker = a.id
|
|
592
608
|
job.sentAt = performance.now()
|
|
593
609
|
a.busy.set(job.req.taskId, job)
|
|
@@ -610,7 +626,6 @@ export class Orchestrator {
|
|
|
610
626
|
if (batch === undefined) batches.set(a, [job])
|
|
611
627
|
else batch.push(job)
|
|
612
628
|
}
|
|
613
|
-
this.queue.push(...left)
|
|
614
629
|
for (const [a, jobs] of batches) this.send(a, jobs)
|
|
615
630
|
}
|
|
616
631
|
|
|
@@ -624,6 +639,23 @@ export class Orchestrator {
|
|
|
624
639
|
private pin(
|
|
625
640
|
job: Job,
|
|
626
641
|
up: ReadonlySet<string>,
|
|
642
|
+
): 'wait' | 'done' | { only?: string; avoid?: string } {
|
|
643
|
+
const where = this.pinUp(job, up)
|
|
644
|
+
const avoid = job.avoid
|
|
645
|
+
if (typeof where !== 'object' || avoid === undefined) return where
|
|
646
|
+
if (where.only !== undefined || where.avoid !== undefined) return where
|
|
647
|
+
// With no other worker that can take it, it runs where it failed rather than wait.
|
|
648
|
+
const platform = job.need.platform
|
|
649
|
+
for (const w of this.workers.values())
|
|
650
|
+
if (w.id !== avoid && !w.faulty && (platform === undefined || w.platform === platform))
|
|
651
|
+
return { avoid }
|
|
652
|
+
return where
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Where `job` may go as its upstreams' files lie (`pin`, before a retry's own rule). */
|
|
656
|
+
private pinUp(
|
|
657
|
+
job: Job,
|
|
658
|
+
up: ReadonlySet<string>,
|
|
627
659
|
): 'wait' | 'done' | { only?: string; avoid?: string } {
|
|
628
660
|
if (job.again !== undefined) {
|
|
629
661
|
const { only } = job.again
|
|
@@ -693,24 +725,31 @@ export class Orchestrator {
|
|
|
693
725
|
this.pumpSoon()
|
|
694
726
|
}
|
|
695
727
|
this.pumpSoon()
|
|
696
|
-
this.
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
728
|
+
this.enqueue(
|
|
729
|
+
[
|
|
730
|
+
{
|
|
731
|
+
req: k.req,
|
|
732
|
+
need: where.platform === undefined ? k.need : { ...k.need, platform: where.platform },
|
|
733
|
+
queuedAt: performance.now(),
|
|
734
|
+
pinnedAt: performance.now(),
|
|
735
|
+
again: {
|
|
736
|
+
...(where.only === undefined ? {} : { only: where.only }),
|
|
737
|
+
...(where.avoid === undefined ? {} : { avoid: where.avoid }),
|
|
738
|
+
},
|
|
739
|
+
resolve: (r) =>
|
|
740
|
+
done(
|
|
741
|
+
r.exitCode === 0
|
|
742
|
+
? undefined
|
|
743
|
+
: `${id} ran again for it and failed (exit ${r.exitCode})`,
|
|
744
|
+
),
|
|
745
|
+
reject: (err) =>
|
|
746
|
+
done(
|
|
747
|
+
`${id} ran again for it and failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
748
|
+
),
|
|
749
|
+
},
|
|
750
|
+
],
|
|
751
|
+
true,
|
|
752
|
+
)
|
|
714
753
|
if (where.only !== undefined || where.avoid !== undefined)
|
|
715
754
|
setTimeout(() => this.pumpSoon(), this.pinWaitMs + 50).unref?.()
|
|
716
755
|
}
|
|
@@ -815,11 +854,13 @@ export class Orchestrator {
|
|
|
815
854
|
* hit only this cache held (the remote evicted it, or this machine does
|
|
816
855
|
* not write it) failed the dependant there (2026-10-24).
|
|
817
856
|
*/
|
|
818
|
-
private async share(
|
|
819
|
-
|
|
857
|
+
private async share(
|
|
858
|
+
up: ReadonlySet<string>,
|
|
859
|
+
nodes: ReadonlyMap<string, TaskNode> | undefined,
|
|
860
|
+
): Promise<void> {
|
|
820
861
|
const keys: string[] = []
|
|
821
|
-
for (const d of
|
|
822
|
-
if (this.savedAs.has(d) || nodes
|
|
862
|
+
for (const d of up) {
|
|
863
|
+
if (this.savedAs.has(d) || this.dep(nodes, d) === undefined) continue
|
|
823
864
|
const key = this.upstreamKeys.get(d) ?? this.o.keyOf(d)
|
|
824
865
|
if (key !== undefined) keys.push(key)
|
|
825
866
|
}
|
|
@@ -860,92 +901,113 @@ export class Orchestrator {
|
|
|
860
901
|
return got.includes(true)
|
|
861
902
|
}
|
|
862
903
|
|
|
904
|
+
/**
|
|
905
|
+
* Upstream `id` as an order names it, once per graph: its project and
|
|
906
|
+
* outputs. Undefined: it is not in the graph, or leaves no files.
|
|
907
|
+
*/
|
|
908
|
+
private dep(nodes: ReadonlyMap<string, TaskNode> | undefined, id: string): DepFiles | undefined {
|
|
909
|
+
if (nodes === undefined) return undefined
|
|
910
|
+
let of = this.deps.get(nodes)
|
|
911
|
+
if (of === undefined) this.deps.set(nodes, (of = new Map()))
|
|
912
|
+
let d = of.get(id)
|
|
913
|
+
if (d === undefined) {
|
|
914
|
+
const n = nodes.get(id)
|
|
915
|
+
const outs = n?.config.cache === true ? n.config.outputs : undefined
|
|
916
|
+
d =
|
|
917
|
+
n === undefined || outs === undefined
|
|
918
|
+
? null
|
|
919
|
+
: {
|
|
920
|
+
project: path.relative(this.o.root, n.projectDir),
|
|
921
|
+
outputs: { files: [...outs.files], workspaceFiles: [...(outs.workspaceFiles ?? [])] },
|
|
922
|
+
}
|
|
923
|
+
of.set(id, d)
|
|
924
|
+
}
|
|
925
|
+
return d ?? undefined
|
|
926
|
+
}
|
|
927
|
+
|
|
863
928
|
private send(a: Worker, jobs: readonly Job[]): void {
|
|
864
929
|
const nodes = this.o.nodes()
|
|
865
|
-
const orders = jobs.map((job) => {
|
|
930
|
+
const orders = jobs.map((job): { job: Job; order: RunOrder } => {
|
|
866
931
|
const { req } = job
|
|
867
932
|
const deps: DepOrder[] = []
|
|
868
933
|
// Every one, held there or not: the worker restores each key once, and
|
|
869
934
|
// 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
|
|
935
|
+
for (const id of job.up ?? upstream(nodes, req.taskId)) {
|
|
936
|
+
const d = this.dep(nodes, id)
|
|
873
937
|
const key = this.savedAs.get(id) ?? this.upstreamKeys.get(id) ?? this.o.keyOf(id)
|
|
874
|
-
if (
|
|
938
|
+
if (d === undefined || key === undefined) continue
|
|
875
939
|
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
|
-
})
|
|
940
|
+
deps.push({ task: id, key, project: d.project, outputs: d.outputs })
|
|
882
941
|
}
|
|
883
942
|
// A task sent here leaves its outputs here for its dependants.
|
|
884
943
|
a.has.add(req.taskId)
|
|
885
944
|
const node = nodes?.get(req.taskId)
|
|
886
945
|
const env = defined(req.env, req.envDefine)
|
|
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
|
-
sandbox
|
|
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
|
-
|
|
946
|
+
const order = {
|
|
947
|
+
task: req.taskId,
|
|
948
|
+
// Unsaved: under the run's own key, as an uncached task's.
|
|
949
|
+
...(req.cacheKey === undefined || req.unsaved === true ? {} : { key: req.cacheKey }),
|
|
950
|
+
runKey: runKey(this.o.run, req.taskId, this.sends(req.taskId)),
|
|
951
|
+
// A held key keeps its entry, and restored here it replaced the
|
|
952
|
+
// forced run's fresh bytes with the ones it bypassed (2026-10-25).
|
|
953
|
+
...(req.refresh === true ? { fresh: true as const } : {}),
|
|
954
|
+
// A HEAD per upload was the workers' only remote probe: the run
|
|
955
|
+
// already knows the key is in no cache (2026-10-25).
|
|
956
|
+
...(req.refresh !== true && this.missed.has(req.taskId) ? { absent: true as const } : {}),
|
|
957
|
+
deps,
|
|
958
|
+
nested: node === undefined ? [] : nestedIn(nodes!, node.projectDir, this.o.root),
|
|
959
|
+
command: withForwardArgs(req.command, req.forwardArgs),
|
|
960
|
+
cwd: path.relative(this.o.root, req.cwd),
|
|
961
|
+
project: node === undefined ? '' : path.relative(this.o.root, node.projectDir),
|
|
962
|
+
env,
|
|
963
|
+
...(node?.config.exec?.env?.secret === undefined
|
|
964
|
+
? {}
|
|
965
|
+
: { secret: [...node.config.exec.env.secret] }),
|
|
966
|
+
...(req.timeoutMs === undefined ? {} : { timeoutMs: req.timeoutMs }),
|
|
967
|
+
outputs: {
|
|
968
|
+
files: [...req.outputs.files],
|
|
969
|
+
workspaceFiles: [...req.outputs.workspaceFiles],
|
|
970
|
+
},
|
|
971
|
+
cores: job.held?.cores ?? 1,
|
|
972
|
+
memory: job.held?.memory ?? 0,
|
|
973
|
+
...(req.sandbox === undefined || node?.config.exec?.sandbox === undefined
|
|
974
|
+
? {}
|
|
975
|
+
: {
|
|
976
|
+
sandbox: {
|
|
977
|
+
config: node.config.exec.sandbox,
|
|
978
|
+
...(req.sandbox.keyed === undefined
|
|
979
|
+
? {}
|
|
980
|
+
: { keyed: req.sandbox.keyed.map((d) => path.relative(this.o.root, d)) }),
|
|
981
|
+
...(req.sandbox.dependencyReads === undefined
|
|
982
|
+
? {}
|
|
983
|
+
: {
|
|
984
|
+
reads: req.sandbox.dependencyReads.map((d) => path.relative(this.o.root, d)),
|
|
985
|
+
}),
|
|
986
|
+
},
|
|
987
|
+
}),
|
|
988
|
+
...runtimeOrders(req),
|
|
989
|
+
...(req.servers === undefined || req.servers.length === 0
|
|
990
|
+
? {}
|
|
991
|
+
: {
|
|
992
|
+
servers: req.servers.map((s) => ({
|
|
993
|
+
task: s.taskId,
|
|
994
|
+
command: s.command,
|
|
995
|
+
cwd: path.relative(this.o.root, s.cwd),
|
|
996
|
+
env: defined(s.env, {}),
|
|
997
|
+
...(s.readyWhen === undefined ? {} : { readyWhen: s.readyWhen }),
|
|
998
|
+
...(s.timeoutMs === undefined ? {} : { timeoutMs: s.timeoutMs }),
|
|
999
|
+
...(s.sandbox === undefined
|
|
1000
|
+
? {}
|
|
1001
|
+
: {
|
|
1002
|
+
sandbox: {
|
|
1003
|
+
config: s.sandbox.config,
|
|
1004
|
+
nested: s.sandbox.nested.map((d) => path.relative(this.o.root, d)),
|
|
1005
|
+
},
|
|
1006
|
+
}),
|
|
1007
|
+
})),
|
|
1008
|
+
}),
|
|
1009
|
+
} as RunOrder
|
|
1010
|
+
return { job, order }
|
|
949
1011
|
})
|
|
950
1012
|
const sandboxed = orders.some(
|
|
951
1013
|
(o) => o.order.sandbox !== undefined || o.order.servers?.some((s) => s.sandbox !== undefined),
|
|
@@ -955,13 +1017,32 @@ export class Orchestrator {
|
|
|
955
1017
|
const pending = new Set(jobs)
|
|
956
1018
|
a.chain = a.chain
|
|
957
1019
|
.then(async () => {
|
|
958
|
-
|
|
1020
|
+
// A dependency the worker was sent under the same key goes by name
|
|
1021
|
+
// alone: each order named every upstream whole, and 8000 tasks of a
|
|
1022
|
+
// deep graph sent 219 MB (2026-11-05). Read here, as sends go in
|
|
1023
|
+
// order: one sent before it fails is not counted as sent.
|
|
1024
|
+
const fresh = new Map<string, string>()
|
|
1025
|
+
const wired = orders.map(({ job, order }) => {
|
|
1026
|
+
const intro: [string, string][] = []
|
|
1027
|
+
const deps = order.deps.map((d): WiredDep => {
|
|
1028
|
+
if ((fresh.get(d.task) ?? a.sentDeps.get(d.task)) === d.key) return d.task
|
|
1029
|
+
fresh.set(d.task, d.key)
|
|
1030
|
+
intro.push([d.task, d.key])
|
|
1031
|
+
return d
|
|
1032
|
+
})
|
|
1033
|
+
const sent: SentOrder = { ...order, deps }
|
|
1034
|
+
// Measured once: a giant repo's were measured three times each (2026-11-05).
|
|
1035
|
+
return { job, order: sent, intro, bytes: bytesOf(sent) }
|
|
1036
|
+
})
|
|
1037
|
+
for (const [i, part] of parts(wired, BATCH_BYTES).entries()) {
|
|
959
1038
|
// An order too long for a frame (a task of thousands of
|
|
960
1039
|
// dependencies was refused, 413) sends its deps ahead.
|
|
961
|
-
const tasks:
|
|
962
|
-
|
|
963
|
-
|
|
1040
|
+
const tasks: SentOrder[] = []
|
|
1041
|
+
let bytes = 0
|
|
1042
|
+
for (const { order, bytes: size } of part) {
|
|
1043
|
+
if (size <= BATCH_BYTES) {
|
|
964
1044
|
tasks.push(order)
|
|
1045
|
+
bytes += size
|
|
965
1046
|
continue
|
|
966
1047
|
}
|
|
967
1048
|
for (const deps of chunks(order.deps, BATCH_BYTES)) {
|
|
@@ -970,7 +1051,9 @@ export class Orchestrator {
|
|
|
970
1051
|
a.usage.messagesIn++
|
|
971
1052
|
a.usage.bytesIn += bytesOf(ahead)
|
|
972
1053
|
}
|
|
973
|
-
|
|
1054
|
+
const alone = { ...order, deps: [] }
|
|
1055
|
+
tasks.push(alone)
|
|
1056
|
+
bytes += bytesOf(alone)
|
|
974
1057
|
}
|
|
975
1058
|
const n = ++a.batches
|
|
976
1059
|
for (const { job } of part) job.batch = n
|
|
@@ -984,7 +1067,9 @@ export class Orchestrator {
|
|
|
984
1067
|
} satisfies ToWorker
|
|
985
1068
|
await this.o.me.send(a.id, batch)
|
|
986
1069
|
a.usage.messagesIn++
|
|
987
|
-
a
|
|
1070
|
+
// Its orders' bytes, a comma between each, in the batch's own.
|
|
1071
|
+
a.usage.bytesIn += bytesOf({ ...batch, tasks: [] }) + bytes + tasks.length - 1
|
|
1072
|
+
for (const { intro } of part) for (const [t, k] of intro) a.sentDeps.set(t, k)
|
|
988
1073
|
for (const { job } of part) {
|
|
989
1074
|
pending.delete(job)
|
|
990
1075
|
// Sent, it runs: its worker takes only what it has room for.
|
|
@@ -1121,7 +1206,7 @@ export class Orchestrator {
|
|
|
1121
1206
|
a.started.delete(task)
|
|
1122
1207
|
const again = this.retry(a, job, `could not read ${head.unread.join(', ')}`)
|
|
1123
1208
|
if (again !== undefined) {
|
|
1124
|
-
this.
|
|
1209
|
+
this.enqueue([again], true)
|
|
1125
1210
|
this.pumpSoon()
|
|
1126
1211
|
}
|
|
1127
1212
|
return
|
|
@@ -1136,7 +1221,15 @@ export class Orchestrator {
|
|
|
1136
1221
|
a.done.push(task)
|
|
1137
1222
|
a.usage.record(head, job.sentAt)
|
|
1138
1223
|
const share = shareOf(head.cpuShare)
|
|
1139
|
-
|
|
1224
|
+
// At least as long as it ran: begun here once its batch's send resolved,
|
|
1225
|
+
// which can be after its worker began it, it was seen for less, and its
|
|
1226
|
+
// CPU over that read as more cores than it had (#5036: 1.001 each, so
|
|
1227
|
+
// two that ran side by side on 2 cores replayed one after the other).
|
|
1228
|
+
const held = Math.max(
|
|
1229
|
+
1,
|
|
1230
|
+
head.durationMs,
|
|
1231
|
+
job.startedAt === undefined ? 0 : endAt - job.startedAt,
|
|
1232
|
+
)
|
|
1140
1233
|
this.took.set(task, {
|
|
1141
1234
|
ms: held,
|
|
1142
1235
|
cores:
|
|
@@ -1162,6 +1255,7 @@ export class Orchestrator {
|
|
|
1162
1255
|
this.needMs += Math.min(job.need.cores, a.cores) * ms
|
|
1163
1256
|
}
|
|
1164
1257
|
this.savedAs.set(task, key)
|
|
1258
|
+
this.ranOn.set(task, a.id)
|
|
1165
1259
|
const node = this.o.nodes()?.get(task)
|
|
1166
1260
|
if (head.exitCode === 0 && node !== undefined && leaves(node))
|
|
1167
1261
|
this.kept.set(task, { worker: a.id, req: job.req, need: job.need })
|
|
@@ -1389,6 +1483,7 @@ export class Orchestrator {
|
|
|
1389
1483
|
started: new Set(),
|
|
1390
1484
|
batches: 0,
|
|
1391
1485
|
has: new Set(),
|
|
1486
|
+
sentDeps: new Map(),
|
|
1392
1487
|
// Its name first, before any batch.
|
|
1393
1488
|
chain: this.cache()
|
|
1394
1489
|
.then((cache) =>
|
|
@@ -1425,7 +1520,7 @@ export class Orchestrator {
|
|
|
1425
1520
|
const again = this.retry(a, j, 'lost')
|
|
1426
1521
|
if (again !== undefined) back.push(again)
|
|
1427
1522
|
}
|
|
1428
|
-
this.
|
|
1523
|
+
this.enqueue(back, true)
|
|
1429
1524
|
this.o.lost?.(a.label)
|
|
1430
1525
|
// Workers that join on their own may yet join; ones this run starts
|
|
1431
1526
|
// are all it gets.
|
|
@@ -1484,7 +1579,7 @@ export class Orchestrator {
|
|
|
1484
1579
|
if (again !== undefined) back.push(again)
|
|
1485
1580
|
}
|
|
1486
1581
|
if (back.length > 0) {
|
|
1487
|
-
this.
|
|
1582
|
+
this.enqueue(back, true)
|
|
1488
1583
|
this.o.faulted?.()
|
|
1489
1584
|
if (this.spawnedAt.size > 0)
|
|
1490
1585
|
this.stranded(`${a.label} no longer runs a task it began and no worker is left`)
|
|
@@ -1505,7 +1600,7 @@ export class Orchestrator {
|
|
|
1505
1600
|
if (j === undefined) continue
|
|
1506
1601
|
// It began it whether or not its `start` came first.
|
|
1507
1602
|
this.begun(a, m.task)
|
|
1508
|
-
if (this.retry(a, j, m.reason) !== undefined) this.
|
|
1603
|
+
if (this.retry(a, j, m.reason) !== undefined) this.enqueue([j], true)
|
|
1509
1604
|
if (a.faulty) this.o.faulted?.()
|
|
1510
1605
|
if (a.faulty && this.spawnedAt.size > 0)
|
|
1511
1606
|
this.stranded(
|
|
@@ -1554,7 +1649,7 @@ export class Orchestrator {
|
|
|
1554
1649
|
where(taskId: string): string | null | undefined {
|
|
1555
1650
|
for (const a of this.workers.values())
|
|
1556
1651
|
if (a.busy.has(taskId)) return a.started.has(taskId) ? a.label : null
|
|
1557
|
-
return this.
|
|
1652
|
+
return this.waiting.has(taskId) ? null : undefined
|
|
1558
1653
|
}
|
|
1559
1654
|
|
|
1560
1655
|
/**
|
|
@@ -1727,7 +1822,8 @@ export class Orchestrator {
|
|
|
1727
1822
|
id: j.req.taskId,
|
|
1728
1823
|
cores: Math.min(a.cores, Math.max((j.held?.cores ?? 0) * over, j.need.ask?.cores ?? 0)),
|
|
1729
1824
|
memory: Math.max(j.held?.memory ?? 0, Math.min(a.memory, j.need.ask?.memory ?? 0)),
|
|
1730
|
-
|
|
1825
|
+
// Below 0: past its time, which the plan weighs (`decide`).
|
|
1826
|
+
endsIn: (ms(j.req.taskId) ?? 0) - (now - j.startedAt!),
|
|
1731
1827
|
})),
|
|
1732
1828
|
})
|
|
1733
1829
|
}
|
|
@@ -1875,7 +1971,11 @@ export class Orchestrator {
|
|
|
1875
1971
|
private fail(reason: string): void {
|
|
1876
1972
|
for (const a of this.workers.values())
|
|
1877
1973
|
for (const j of a.busy.values()) j.reject(new Error(reason))
|
|
1878
|
-
for (const j of this.
|
|
1974
|
+
for (const [id, j] of this.waiting) {
|
|
1975
|
+
this.queue.delete(id)
|
|
1976
|
+
j.reject(new Error(reason))
|
|
1977
|
+
}
|
|
1978
|
+
this.waiting.clear()
|
|
1879
1979
|
this.workers.clear()
|
|
1880
1980
|
this.lost = reason
|
|
1881
1981
|
}
|
|
@@ -1899,13 +1999,8 @@ export class Orchestrator {
|
|
|
1899
1999
|
* waits on its deps through those no worker ran (groups, hits).
|
|
1900
2000
|
*/
|
|
1901
2001
|
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
|
|
2002
|
+
const r = this.replay(depsOf)
|
|
2003
|
+
if (r === undefined) return undefined
|
|
1909
2004
|
const pool: PlanWorker[] = (
|
|
1910
2005
|
sizes.length > 0
|
|
1911
2006
|
? sizes.flatMap((s) =>
|
|
@@ -1915,14 +2010,104 @@ export class Orchestrator {
|
|
|
1915
2010
|
platform: s.platform,
|
|
1916
2011
|
})),
|
|
1917
2012
|
)
|
|
1918
|
-
:
|
|
2013
|
+
: [...this.workers.values(), ...this.gone].map((a) => ({
|
|
2014
|
+
cores: a.cores,
|
|
2015
|
+
memory: a.memory,
|
|
2016
|
+
platform: a.platform,
|
|
2017
|
+
}))
|
|
1919
2018
|
).map((w, i) => ({
|
|
1920
2019
|
...w,
|
|
1921
2020
|
id: `ideal#${i}`,
|
|
1922
|
-
at: warm
|
|
2021
|
+
at: r.warm(w.platform),
|
|
1923
2022
|
running: [],
|
|
1924
2023
|
idle: false,
|
|
1925
2024
|
}))
|
|
2025
|
+
const end = simulate(r.tasks, pool)
|
|
2026
|
+
if (!Number.isFinite(end)) return undefined
|
|
2027
|
+
return r.from + end
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/**
|
|
2031
|
+
* The workers that would have run this run best (`advise`), from the
|
|
2032
|
+
* same replay as `idealMs`: on `sizes`, past their `max`, and of any
|
|
2033
|
+
* cores. With no sizes (workers that join on their own), the shapes
|
|
2034
|
+
* that joined stand for them, as many as joined.
|
|
2035
|
+
*/
|
|
2036
|
+
advice(depsOf: (id: string) => readonly string[], sizes: readonly Size[]): WorkerAdvice[] {
|
|
2037
|
+
// The footer and the summary each ask once the run ended: played once.
|
|
2038
|
+
if (this.ended && this.advised !== undefined) return this.advised
|
|
2039
|
+
const out = this.advise(depsOf, sizes)
|
|
2040
|
+
if (this.ended) this.advised = out
|
|
2041
|
+
return out
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
private advised: WorkerAdvice[] | undefined
|
|
2045
|
+
|
|
2046
|
+
private advise(
|
|
2047
|
+
depsOf: (id: string) => readonly string[],
|
|
2048
|
+
sizes: readonly Size[],
|
|
2049
|
+
): WorkerAdvice[] {
|
|
2050
|
+
const r = this.replay(depsOf)
|
|
2051
|
+
if (r === undefined) return []
|
|
2052
|
+
const shapes: Size[] =
|
|
2053
|
+
sizes.length > 0
|
|
2054
|
+
? [...sizes]
|
|
2055
|
+
: [
|
|
2056
|
+
...[...this.workers.values(), ...this.gone]
|
|
2057
|
+
.reduce((by, a) => {
|
|
2058
|
+
const name = `${a.cores}c/${a.platform ?? ''}`
|
|
2059
|
+
const had = by.get(name)
|
|
2060
|
+
by.set(name, {
|
|
2061
|
+
name,
|
|
2062
|
+
max: (had?.max ?? 0) + 1,
|
|
2063
|
+
cpu: a.cores,
|
|
2064
|
+
memory: a.memory,
|
|
2065
|
+
platform: a.platform,
|
|
2066
|
+
})
|
|
2067
|
+
return by
|
|
2068
|
+
}, new Map<string, Size>())
|
|
2069
|
+
.values(),
|
|
2070
|
+
]
|
|
2071
|
+
const a = advise(r.tasks, shapes, r.warm)
|
|
2072
|
+
const wire = (of: WorkerAdvice['of'], f: Fleets | undefined): WorkerAdvice[] =>
|
|
2073
|
+
f === undefined
|
|
2074
|
+
? []
|
|
2075
|
+
: [
|
|
2076
|
+
{
|
|
2077
|
+
of,
|
|
2078
|
+
endMs: Math.round(r.from + f.endMs),
|
|
2079
|
+
workers: f.fleet.map((w) => ({
|
|
2080
|
+
count: w.count,
|
|
2081
|
+
cores: w.cores,
|
|
2082
|
+
memoryBytes: w.memory,
|
|
2083
|
+
...(w.platform === undefined ? {} : { platform: w.platform }),
|
|
2084
|
+
...(w.size === undefined || sizes.length === 0 ? {} : { size: w.size }),
|
|
2085
|
+
})),
|
|
2086
|
+
},
|
|
2087
|
+
]
|
|
2088
|
+
return [...wire('declared', a.declared), ...wire('more', a.more), ...wire('any', a.any)]
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
/**
|
|
2092
|
+
* Each task a worker ran, as long as it held its worker and with the
|
|
2093
|
+
* cores it used meanwhile, waiting on its deps through those no worker
|
|
2094
|
+
* ran (groups, hits); each platform's fastest real warm-up; and when
|
|
2095
|
+
* the run first knew its work.
|
|
2096
|
+
*/
|
|
2097
|
+
private replay(depsOf: (id: string) => readonly string[]):
|
|
2098
|
+
| {
|
|
2099
|
+
tasks: PlanTask[]
|
|
2100
|
+
warm: (platform: string | undefined) => number
|
|
2101
|
+
from: number
|
|
2102
|
+
}
|
|
2103
|
+
| undefined {
|
|
2104
|
+
if (this.took.size === 0) return undefined
|
|
2105
|
+
const all = [...this.workers.values(), ...this.gone]
|
|
2106
|
+
const warm = new Map<string | undefined, number>()
|
|
2107
|
+
for (const a of all)
|
|
2108
|
+
if (a.warmUpMs !== undefined && !(warm.get(a.platform)! <= a.warmUpMs))
|
|
2109
|
+
warm.set(a.platform, a.warmUpMs)
|
|
2110
|
+
const least = this.warmUpMs() ?? 0
|
|
1926
2111
|
const seen = new Map<string, string[]>()
|
|
1927
2112
|
const ran = (id: string): string[] => {
|
|
1928
2113
|
const known = seen.get(id)
|
|
@@ -1932,10 +2117,11 @@ export class Orchestrator {
|
|
|
1932
2117
|
seen.set(id, out)
|
|
1933
2118
|
return out
|
|
1934
2119
|
}
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
2120
|
+
return {
|
|
2121
|
+
tasks: [...this.took].map(([id, t]) => ({ id, ...t, deps: ran(id) })),
|
|
2122
|
+
warm: (platform) => warm.get(platform) ?? least,
|
|
2123
|
+
from: this.probe?.at ?? this.asks[0]?.at ?? 0,
|
|
2124
|
+
}
|
|
1939
2125
|
}
|
|
1940
2126
|
|
|
1941
2127
|
/**
|
|
@@ -1994,7 +2180,10 @@ export class Orchestrator {
|
|
|
1994
2180
|
* each, the cores and memory its size declares and its machine reports;
|
|
1995
2181
|
* and `ideal`, the run's `idealMs`, asked once it ended.
|
|
1996
2182
|
*/
|
|
1997
|
-
footer(
|
|
2183
|
+
footer(
|
|
2184
|
+
ideal?: () => number | undefined,
|
|
2185
|
+
advice?: () => readonly WorkerAdvice[],
|
|
2186
|
+
): [string, ui.Inline][] {
|
|
1998
2187
|
// Over with none, it says so: core's own `N workers` (this machine's
|
|
1999
2188
|
// pool) read as the run's when no task needed one (2026-10-25).
|
|
2000
2189
|
if (this.spawnedAt.size === 0 && this.workers.size === 0 && this.gone.length === 0)
|
|
@@ -2125,6 +2314,17 @@ export class Orchestrator {
|
|
|
2125
2314
|
),
|
|
2126
2315
|
] as [string, ui.Inline],
|
|
2127
2316
|
])(ideal?.()),
|
|
2317
|
+
// What to declare next time (2026-11-05: "So I can add more
|
|
2318
|
+
// or change their specs").
|
|
2319
|
+
...(advice?.() ?? []).map(
|
|
2320
|
+
(a) =>
|
|
2321
|
+
[
|
|
2322
|
+
'',
|
|
2323
|
+
ui.dim(
|
|
2324
|
+
`${ADVICE[a.of][0].padEnd(width)} ${a.workers.map(fleetText).join(' + ')}: ${seconds(a.endMs)}, ${ADVICE[a.of][1]}`,
|
|
2325
|
+
),
|
|
2326
|
+
] as [string, ui.Inline],
|
|
2327
|
+
),
|
|
2128
2328
|
...(this.unmeasured.length === 0
|
|
2129
2329
|
? []
|
|
2130
2330
|
: [
|
|
@@ -2190,15 +2390,28 @@ export function nestedIn(
|
|
|
2190
2390
|
): string[] {
|
|
2191
2391
|
let of = nested.get(nodes)
|
|
2192
2392
|
if (of === undefined) {
|
|
2193
|
-
of = {
|
|
2393
|
+
of = {
|
|
2394
|
+
dirs: [...new Set([...nodes.values()].map((n) => n.projectDir))].sort(),
|
|
2395
|
+
by: new Map(),
|
|
2396
|
+
}
|
|
2194
2397
|
nested.set(nodes, of)
|
|
2195
2398
|
}
|
|
2196
2399
|
let out = of.by.get(dir)
|
|
2197
2400
|
if (out === undefined) {
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2401
|
+
// Sorted, the ones under `dir` lie together: each project read every
|
|
2402
|
+
// project, 16 million reads for 4000 (2026-11-05).
|
|
2403
|
+
const under = dir + path.sep
|
|
2404
|
+
let lo = 0
|
|
2405
|
+
let hi = of.dirs.length
|
|
2406
|
+
while (lo < hi) {
|
|
2407
|
+
const mid = (lo + hi) >> 1
|
|
2408
|
+
if (of.dirs[mid]! < under) lo = mid + 1
|
|
2409
|
+
else hi = mid
|
|
2410
|
+
}
|
|
2411
|
+
const found: string[] = []
|
|
2412
|
+
for (let i = lo; i < of.dirs.length && of.dirs[i]!.startsWith(under); i++)
|
|
2413
|
+
found.push(path.relative(root, of.dirs[i]!))
|
|
2414
|
+
out = found.sort()
|
|
2202
2415
|
of.by.set(dir, out)
|
|
2203
2416
|
}
|
|
2204
2417
|
return [...out]
|
|
@@ -2250,7 +2463,7 @@ function sandboxConfigs(nodes: ReadonlyMap<string, TaskNode> | undefined): Sandb
|
|
|
2250
2463
|
* pinning every dependant to its worker held a run's shards on one.
|
|
2251
2464
|
*/
|
|
2252
2465
|
function leaves(node: TaskNode): boolean {
|
|
2253
|
-
if (node.config.cache
|
|
2466
|
+
if (node.config.cache === true) return false
|
|
2254
2467
|
const sandbox = node.config.exec?.sandbox
|
|
2255
2468
|
return sandbox === undefined || (sandbox.allow?.write?.length ?? 0) > 0
|
|
2256
2469
|
}
|