@vzn/vx-dte 0.0.650 → 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 +75 -31
- package/package.json +3 -3
- package/src/dispatch.ts +501 -26
- package/src/forecast.ts +1 -2
- package/src/github.ts +16 -5
- package/src/messages.ts +52 -31
- package/src/orchestrator.ts +551 -352
- package/src/planner.ts +1322 -172
- package/src/plugin.ts +48 -8
- package/src/seal.ts +11 -4
- package/src/wire-host.ts +98 -0
- package/src/wire-thread.ts +168 -0
- package/src/wire.ts +117 -68
- package/src/worker.ts +112 -62
package/src/orchestrator.ts
CHANGED
|
@@ -25,10 +25,10 @@ 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,
|
|
31
|
-
HELD_EVERY,
|
|
32
32
|
HOST_ENV,
|
|
33
33
|
PART_LISTS,
|
|
34
34
|
type PartList,
|
|
@@ -36,14 +36,24 @@ import {
|
|
|
36
36
|
type DepOrder,
|
|
37
37
|
type RunOrder,
|
|
38
38
|
type RuntimeOrder,
|
|
39
|
+
type SentOrder,
|
|
40
|
+
type WiredDep,
|
|
39
41
|
type ToWorker,
|
|
40
42
|
type Load,
|
|
41
43
|
type ToOrchestrator,
|
|
42
44
|
type WiredResult,
|
|
43
45
|
} from './messages.js'
|
|
44
|
-
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'
|
|
45
55
|
import { Usage, type UsageStats } from './usage.js'
|
|
46
|
-
import {
|
|
56
|
+
import { type Need, Queue } from './dispatch.js'
|
|
47
57
|
import { DEFAULT_STRATEGY, type Strategy } from './strategy.js'
|
|
48
58
|
import { RelayError, unsent, type Participant } from './wire.js'
|
|
49
59
|
import { ORCHESTRATOR, Trace } from './trace.js'
|
|
@@ -65,11 +75,13 @@ interface Job {
|
|
|
65
75
|
booked?: number
|
|
66
76
|
/** The workers that failed it after beginning it, with why: two fail it. */
|
|
67
77
|
faults?: string[]
|
|
68
|
-
/** Its worker's
|
|
69
|
-
|
|
78
|
+
/** Its worker's `end` came: a report naming it no more is not a loss. */
|
|
79
|
+
ended?: true
|
|
70
80
|
/** When it was sent to its worker. */
|
|
71
81
|
sentAt?: number
|
|
72
|
-
/**
|
|
82
|
+
/** The batch that sent it, by its worker's count: a report of that batch or later names it. */
|
|
83
|
+
batch?: number
|
|
84
|
+
/** When its worker began it: its send, then the time its worker says. */
|
|
73
85
|
startedAt?: number
|
|
74
86
|
/** When its worker said its command ended: it saves since. */
|
|
75
87
|
ranAt?: number
|
|
@@ -83,8 +95,15 @@ interface Job {
|
|
|
83
95
|
reread?: Set<string>
|
|
84
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). */
|
|
85
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
|
|
86
102
|
}
|
|
87
103
|
|
|
104
|
+
/** An upstream's files, as an order names them (`DepOrder`). */
|
|
105
|
+
type DepFiles = Pick<DepOrder, 'project' | 'outputs'>
|
|
106
|
+
|
|
88
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). */
|
|
89
108
|
const PIN_WAIT_MS = 10_000
|
|
90
109
|
|
|
@@ -112,14 +131,18 @@ interface Worker {
|
|
|
112
131
|
readonly usage: Usage
|
|
113
132
|
/** Sent to it and not finished: running there or in its queue. */
|
|
114
133
|
readonly busy: Map<string, Job>
|
|
115
|
-
/** Of `busy`, the ones it began. */
|
|
134
|
+
/** Of `busy`, the ones it began: each one sent, as a worker runs what it takes at once. */
|
|
116
135
|
readonly started: Set<string>
|
|
136
|
+
/** Batches sent it: each batch's `n`. */
|
|
137
|
+
batches: number
|
|
117
138
|
usedCores: number
|
|
118
139
|
usedMemory: number
|
|
119
140
|
/** Its machine's use as its last frame said, and when this machine heard it. */
|
|
120
141
|
load?: { cpu: number; memory: number; at: number }
|
|
121
142
|
/** Tasks whose outputs it holds: it ran them, or was sent them. */
|
|
122
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>
|
|
123
146
|
/** Sends to it go out in order: a run's parts before the run. */
|
|
124
147
|
chain: Promise<void>
|
|
125
148
|
/** It was sent the run's sandbox configs. */
|
|
@@ -164,7 +187,9 @@ export interface OrchestratorOptions {
|
|
|
164
187
|
readonly joined?: () => void
|
|
165
188
|
/** Worker `id` did not join within twice its size's first join: given up, for its adapter to stop. */
|
|
166
189
|
readonly overdue?: (id: string) => void
|
|
167
|
-
/**
|
|
190
|
+
/** Worker `id`, given up as late (`overdue`), joined after all and is taken. */
|
|
191
|
+
readonly kept?: (id: string) => void
|
|
192
|
+
/** A worker failed a task or dropped it: the run plans again, now, so a replacement starts. */
|
|
168
193
|
readonly faulted?: () => void
|
|
169
194
|
/** How much of the run waits on a task (`tailsMs`): the queue places the most first. */
|
|
170
195
|
readonly tail?: (taskId: string) => number
|
|
@@ -202,6 +227,15 @@ const BATCH_BYTES = 512 * 1024
|
|
|
202
227
|
const FEW = 2
|
|
203
228
|
/** The unmeasured tasks the summary names; the rest it counts. */
|
|
204
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}`})`
|
|
205
239
|
/** Core-time of history the run's tasks finish before their use reshapes bookings: a few tasks' worth. */
|
|
206
240
|
const LEARN_MS = 60_000
|
|
207
241
|
/** The most a worker's bookings may add up to, in its cores, when tasks use less than their history. */
|
|
@@ -210,12 +244,12 @@ const OVERBOOK = 1.5
|
|
|
210
244
|
const RESTARTS = 3
|
|
211
245
|
|
|
212
246
|
/** `orders` in runs of at most `max` bytes of JSON; one bigger goes alone. */
|
|
213
|
-
function parts<T extends {
|
|
247
|
+
function parts<T extends { bytes: number }>(orders: readonly T[], max: number): T[][] {
|
|
214
248
|
const out: T[][] = []
|
|
215
249
|
let part: T[] = []
|
|
216
250
|
let bytes = 0
|
|
217
251
|
for (const o of orders) {
|
|
218
|
-
const size =
|
|
252
|
+
const size = o.bytes
|
|
219
253
|
if (part.length > 0 && bytes + size > max) {
|
|
220
254
|
out.push(part)
|
|
221
255
|
part = []
|
|
@@ -254,9 +288,13 @@ export class Orchestrator {
|
|
|
254
288
|
private readonly workers = new Map<string, Worker>()
|
|
255
289
|
/** The run tree's files, workspace-relative, listed when a task first asks. */
|
|
256
290
|
private inTree?: Promise<Tree | undefined>
|
|
257
|
-
|
|
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
|
|
258
294
|
/** Where a worker saved each task it ran: its cache key or its run key. */
|
|
259
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>()
|
|
260
298
|
/** Keys the run's requests named for their dependencies. */
|
|
261
299
|
private readonly upstreamKeys = new Map<string, string>()
|
|
262
300
|
/** How many times each task was sent: its run key is one per send. */
|
|
@@ -349,9 +387,12 @@ export class Orchestrator {
|
|
|
349
387
|
private relend: ReturnType<typeof setTimeout> | undefined
|
|
350
388
|
/** The plugins any of the run's lends lent a token. */
|
|
351
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>>()
|
|
352
392
|
|
|
353
393
|
constructor(private readonly o: OrchestratorOptions) {
|
|
354
394
|
this.sent = o.sent ?? new Map()
|
|
395
|
+
this.queue = new Queue(o.how ?? DEFAULT_STRATEGY, o.tail, (id) => this.waiting.get(id)!.need)
|
|
355
396
|
// A root that is gone fails the run's own reads first.
|
|
356
397
|
this.realRoot = (() => {
|
|
357
398
|
try {
|
|
@@ -361,19 +402,16 @@ export class Orchestrator {
|
|
|
361
402
|
return o.root
|
|
362
403
|
}
|
|
363
404
|
})()
|
|
364
|
-
// Leases ran on while either side was away, as a relay restarted: what
|
|
365
|
-
// a worker holds is still its own, not run again elsewhere
|
|
366
|
-
// (2026-10-25: "make sure on rejoin we don't repeat work").
|
|
367
|
-
o.me.resumed((peer) => {
|
|
368
|
-
for (const a of this.workers.values())
|
|
369
|
-
if (peer === undefined || a.id === peer)
|
|
370
|
-
for (const j of a.busy.values()) if (j.leaseUntil !== undefined) this.lease(j)
|
|
371
|
-
})
|
|
372
405
|
this.loop = this.listen()
|
|
373
406
|
}
|
|
374
407
|
|
|
375
408
|
async execute(req: ExecuteRequest): Promise<ExecuteResult> {
|
|
376
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)
|
|
377
415
|
// A worker runs the run's tree: a file its key includes that the tree
|
|
378
416
|
// lacks (untracked) is not there, and the output it saved under that
|
|
379
417
|
// key was a stale hit for the next run (2026-10-24).
|
|
@@ -383,25 +421,16 @@ export class Orchestrator {
|
|
|
383
421
|
// A dependency's output is restored there before the task runs: a
|
|
384
422
|
// key that reads an upstream's bytes (`inputs.tasks` leaving it out)
|
|
385
423
|
// was refused (2026-10-25).
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
: [
|
|
393
|
-
names(path.relative(this.o.root, n.projectDir), {
|
|
394
|
-
files: outs.files,
|
|
395
|
-
workspaceFiles: outs.workspaceFiles ?? [],
|
|
396
|
-
}),
|
|
397
|
-
]
|
|
398
|
-
})
|
|
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))
|
|
399
430
|
const stray =
|
|
400
431
|
tree === undefined
|
|
401
432
|
? undefined
|
|
402
|
-
: req.inputs.files.find(
|
|
403
|
-
(f) => !tree.files.has(f.path) && !restored.some((made) => made(f.path)),
|
|
404
|
-
)
|
|
433
|
+
: req.inputs.files.find((f) => !tree.files.has(f.path) && !made(f.path))
|
|
405
434
|
if (stray !== undefined) {
|
|
406
435
|
// A committed submodule's files are not the tree's: its gitlink is.
|
|
407
436
|
const link = tree?.links.find((l) => stray.path.startsWith(`${l}/`))
|
|
@@ -413,20 +442,24 @@ export class Orchestrator {
|
|
|
413
442
|
}
|
|
414
443
|
}
|
|
415
444
|
for (const u of req.inputs?.upstream ?? []) this.upstreamKeys.set(u.taskId, u.hash)
|
|
416
|
-
await this.share(
|
|
445
|
+
await this.share(up, nodes)
|
|
417
446
|
return new Promise((resolve, reject) => {
|
|
418
447
|
const need = this.o.need(req.taskId)
|
|
419
448
|
const job: Job = {
|
|
420
449
|
req,
|
|
450
|
+
...(nodes?.has(req.taskId) === true ? { up } : {}),
|
|
421
451
|
need: { ...need, platform: req.platform ?? this.o.home },
|
|
422
452
|
queuedAt: performance.now(),
|
|
423
453
|
resolve,
|
|
424
454
|
reject,
|
|
425
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
|
|
426
459
|
// A signal aborted already fires no `abort`.
|
|
427
460
|
if (req.signal?.aborted === true) return resolve(stopped())
|
|
428
461
|
req.signal?.addEventListener('abort', () => this.abort(job), { once: true })
|
|
429
|
-
this.
|
|
462
|
+
this.enqueue([job])
|
|
430
463
|
this.pumpSoon()
|
|
431
464
|
})
|
|
432
465
|
}
|
|
@@ -502,9 +535,9 @@ export class Orchestrator {
|
|
|
502
535
|
}
|
|
503
536
|
|
|
504
537
|
private abort(job: Job): void {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
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)
|
|
508
541
|
job.resolve(stopped())
|
|
509
542
|
} else if (job.worker !== undefined) {
|
|
510
543
|
const a = this.workers.get(job.worker)
|
|
@@ -522,27 +555,22 @@ export class Orchestrator {
|
|
|
522
555
|
})
|
|
523
556
|
}
|
|
524
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
|
+
|
|
525
567
|
private pump(): void {
|
|
526
568
|
const batches = new Map<Worker, Job[]>()
|
|
527
|
-
const left: Job[] = []
|
|
528
569
|
// Core asks in its critical-path order, but only as tasks get ready: a
|
|
529
570
|
// long chain ready a moment late queued behind 46 shards ready at once,
|
|
530
571
|
// and its 41 s build began 47 s in, the run's end (main, 2026-10-23).
|
|
531
|
-
const tail = this.o.tail
|
|
532
|
-
const how = this.o.how ?? DEFAULT_STRATEGY
|
|
533
|
-
const waiting = this.queue.splice(0)
|
|
534
572
|
const workers = [...this.workers.values()].filter((a) => !a.faulty)
|
|
535
|
-
const byId =
|
|
536
|
-
const queue =
|
|
537
|
-
tail === undefined
|
|
538
|
-
? waiting
|
|
539
|
-
: ordered(
|
|
540
|
-
waiting.map((j) => j.req.taskId),
|
|
541
|
-
how.order,
|
|
542
|
-
tail,
|
|
543
|
-
(id) => byId.get(id)!.need,
|
|
544
|
-
Math.max(1, ...workers.map((a) => a.cores)),
|
|
545
|
-
).map((id) => byId.get(id)!)
|
|
573
|
+
const byId = this.waiting
|
|
546
574
|
const bins = workers.map((a) => {
|
|
547
575
|
let running = 0
|
|
548
576
|
for (const j of a.busy.values()) if (j.held !== undefined) running++
|
|
@@ -558,39 +586,24 @@ export class Orchestrator {
|
|
|
558
586
|
}
|
|
559
587
|
})
|
|
560
588
|
const nodes = this.o.nodes()
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
job
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (pin === 'wait') {
|
|
575
|
-
left.push(job)
|
|
576
|
-
continue
|
|
577
|
-
}
|
|
578
|
-
placed.push(job)
|
|
579
|
-
const need = this.booking(job.need)
|
|
580
|
-
floor.cores = Math.min(floor.cores, need.cores)
|
|
581
|
-
floor.memory = Math.min(floor.memory, need.memory)
|
|
582
|
-
asks.push({ id: job.req.taskId, need, up, ...pin })
|
|
583
|
-
}
|
|
584
|
-
const to = assign(asks, bins, (above, platform) => this.bigger(above, platform), how.place, {
|
|
585
|
-
floor,
|
|
586
|
-
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),
|
|
587
602
|
})
|
|
603
|
+
const placed = [...to.keys()].map((id) => byId.get(id)!)
|
|
604
|
+
for (const id of to.keys()) byId.delete(id)
|
|
588
605
|
for (const job of placed) {
|
|
589
|
-
const a = to.get(job.req.taskId)
|
|
590
|
-
if (a === undefined) {
|
|
591
|
-
left.push(job)
|
|
592
|
-
continue
|
|
593
|
-
}
|
|
606
|
+
const a = to.get(job.req.taskId)!.a
|
|
594
607
|
job.worker = a.id
|
|
595
608
|
job.sentAt = performance.now()
|
|
596
609
|
a.busy.set(job.req.taskId, job)
|
|
@@ -613,7 +626,6 @@ export class Orchestrator {
|
|
|
613
626
|
if (batch === undefined) batches.set(a, [job])
|
|
614
627
|
else batch.push(job)
|
|
615
628
|
}
|
|
616
|
-
this.queue.push(...left)
|
|
617
629
|
for (const [a, jobs] of batches) this.send(a, jobs)
|
|
618
630
|
}
|
|
619
631
|
|
|
@@ -627,6 +639,23 @@ export class Orchestrator {
|
|
|
627
639
|
private pin(
|
|
628
640
|
job: Job,
|
|
629
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>,
|
|
630
659
|
): 'wait' | 'done' | { only?: string; avoid?: string } {
|
|
631
660
|
if (job.again !== undefined) {
|
|
632
661
|
const { only } = job.again
|
|
@@ -696,24 +725,31 @@ export class Orchestrator {
|
|
|
696
725
|
this.pumpSoon()
|
|
697
726
|
}
|
|
698
727
|
this.pumpSoon()
|
|
699
|
-
this.
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
+
)
|
|
717
753
|
if (where.only !== undefined || where.avoid !== undefined)
|
|
718
754
|
setTimeout(() => this.pumpSoon(), this.pinWaitMs + 50).unref?.()
|
|
719
755
|
}
|
|
@@ -818,11 +854,13 @@ export class Orchestrator {
|
|
|
818
854
|
* hit only this cache held (the remote evicted it, or this machine does
|
|
819
855
|
* not write it) failed the dependant there (2026-10-24).
|
|
820
856
|
*/
|
|
821
|
-
private async share(
|
|
822
|
-
|
|
857
|
+
private async share(
|
|
858
|
+
up: ReadonlySet<string>,
|
|
859
|
+
nodes: ReadonlyMap<string, TaskNode> | undefined,
|
|
860
|
+
): Promise<void> {
|
|
823
861
|
const keys: string[] = []
|
|
824
|
-
for (const d of
|
|
825
|
-
if (this.savedAs.has(d) || nodes
|
|
862
|
+
for (const d of up) {
|
|
863
|
+
if (this.savedAs.has(d) || this.dep(nodes, d) === undefined) continue
|
|
826
864
|
const key = this.upstreamKeys.get(d) ?? this.o.keyOf(d)
|
|
827
865
|
if (key !== undefined) keys.push(key)
|
|
828
866
|
}
|
|
@@ -863,92 +901,113 @@ export class Orchestrator {
|
|
|
863
901
|
return got.includes(true)
|
|
864
902
|
}
|
|
865
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
|
+
|
|
866
928
|
private send(a: Worker, jobs: readonly Job[]): void {
|
|
867
929
|
const nodes = this.o.nodes()
|
|
868
|
-
const orders = jobs.map((job) => {
|
|
930
|
+
const orders = jobs.map((job): { job: Job; order: RunOrder } => {
|
|
869
931
|
const { req } = job
|
|
870
932
|
const deps: DepOrder[] = []
|
|
871
933
|
// Every one, held there or not: the worker restores each key once, and
|
|
872
934
|
// one it was sent but gave back it never ran or restored.
|
|
873
|
-
for (const id of upstream(nodes, req.taskId)) {
|
|
874
|
-
const
|
|
875
|
-
const outs = n?.config.cache?.outputs
|
|
935
|
+
for (const id of job.up ?? upstream(nodes, req.taskId)) {
|
|
936
|
+
const d = this.dep(nodes, id)
|
|
876
937
|
const key = this.savedAs.get(id) ?? this.upstreamKeys.get(id) ?? this.o.keyOf(id)
|
|
877
|
-
if (
|
|
938
|
+
if (d === undefined || key === undefined) continue
|
|
878
939
|
a.has.add(id)
|
|
879
|
-
deps.push({
|
|
880
|
-
task: id,
|
|
881
|
-
key,
|
|
882
|
-
project: path.relative(this.o.root, n.projectDir),
|
|
883
|
-
outputs: { files: [...outs.files], workspaceFiles: [...(outs.workspaceFiles ?? [])] },
|
|
884
|
-
})
|
|
940
|
+
deps.push({ task: id, key, project: d.project, outputs: d.outputs })
|
|
885
941
|
}
|
|
886
942
|
// A task sent here leaves its outputs here for its dependants.
|
|
887
943
|
a.has.add(req.taskId)
|
|
888
944
|
const node = nodes?.get(req.taskId)
|
|
889
945
|
const env = defined(req.env, req.envDefine)
|
|
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
|
-
sandbox
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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 }
|
|
952
1011
|
})
|
|
953
1012
|
const sandboxed = orders.some(
|
|
954
1013
|
(o) => o.order.sandbox !== undefined || o.order.servers?.some((s) => s.sandbox !== undefined),
|
|
@@ -958,13 +1017,32 @@ export class Orchestrator {
|
|
|
958
1017
|
const pending = new Set(jobs)
|
|
959
1018
|
a.chain = a.chain
|
|
960
1019
|
.then(async () => {
|
|
961
|
-
|
|
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()) {
|
|
962
1038
|
// An order too long for a frame (a task of thousands of
|
|
963
1039
|
// dependencies was refused, 413) sends its deps ahead.
|
|
964
|
-
const tasks:
|
|
965
|
-
|
|
966
|
-
|
|
1040
|
+
const tasks: SentOrder[] = []
|
|
1041
|
+
let bytes = 0
|
|
1042
|
+
for (const { order, bytes: size } of part) {
|
|
1043
|
+
if (size <= BATCH_BYTES) {
|
|
967
1044
|
tasks.push(order)
|
|
1045
|
+
bytes += size
|
|
968
1046
|
continue
|
|
969
1047
|
}
|
|
970
1048
|
for (const deps of chunks(order.deps, BATCH_BYTES)) {
|
|
@@ -973,10 +1051,15 @@ export class Orchestrator {
|
|
|
973
1051
|
a.usage.messagesIn++
|
|
974
1052
|
a.usage.bytesIn += bytesOf(ahead)
|
|
975
1053
|
}
|
|
976
|
-
|
|
1054
|
+
const alone = { ...order, deps: [] }
|
|
1055
|
+
tasks.push(alone)
|
|
1056
|
+
bytes += bytesOf(alone)
|
|
977
1057
|
}
|
|
1058
|
+
const n = ++a.batches
|
|
1059
|
+
for (const { job } of part) job.batch = n
|
|
978
1060
|
const batch = {
|
|
979
1061
|
type: 'batch',
|
|
1062
|
+
n,
|
|
980
1063
|
root: this.o.root,
|
|
981
1064
|
realRoot: this.realRoot,
|
|
982
1065
|
tasks,
|
|
@@ -984,11 +1067,15 @@ 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
|
-
this.lease(job)
|
|
990
1074
|
pending.delete(job)
|
|
1075
|
+
// Sent, it runs: its worker takes only what it has room for.
|
|
1076
|
+
this.begun(a, job.req.taskId)
|
|
991
1077
|
}
|
|
1078
|
+
this.pumpSoon()
|
|
992
1079
|
}
|
|
993
1080
|
})
|
|
994
1081
|
.catch((err: unknown) => {
|
|
@@ -1006,13 +1093,8 @@ export class Orchestrator {
|
|
|
1006
1093
|
})
|
|
1007
1094
|
}
|
|
1008
1095
|
|
|
1009
|
-
/** Three of the worker's `held` from now. */
|
|
1010
|
-
private lease(job: Job): void {
|
|
1011
|
-
job.leaseUntil = performance.now() + 3 * HELD_EVERY * this.o.me.heartbeatMs
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
1096
|
/**
|
|
1015
|
-
* Worker `a` let `job` down: lost, its
|
|
1097
|
+
* Worker `a` let `job` down: lost, its report named it no more, or it could not run
|
|
1016
1098
|
* it. One it had begun counts, and two workers fail it; one still in its
|
|
1017
1099
|
* queue goes back free.
|
|
1018
1100
|
*/
|
|
@@ -1032,9 +1114,10 @@ export class Orchestrator {
|
|
|
1032
1114
|
a.has.delete(job.req.taskId)
|
|
1033
1115
|
this.unhold(a, job)
|
|
1034
1116
|
delete job.worker
|
|
1035
|
-
delete job.leaseUntil
|
|
1036
1117
|
delete job.sentAt
|
|
1118
|
+
delete job.ended
|
|
1037
1119
|
delete job.ranAt
|
|
1120
|
+
delete job.batch
|
|
1038
1121
|
const begun = a.started.delete(job.req.taskId)
|
|
1039
1122
|
// Stopped, it runs nowhere again; the cancel sent its worker has no one to answer it.
|
|
1040
1123
|
if (job.req.signal?.aborted === true) {
|
|
@@ -1078,25 +1161,6 @@ export class Orchestrator {
|
|
|
1078
1161
|
this.o.stalled?.(lines.join('\n'))
|
|
1079
1162
|
}
|
|
1080
1163
|
|
|
1081
|
-
/** Each task whose worker's lease on it ended goes back. */
|
|
1082
|
-
private expire(): void {
|
|
1083
|
-
// With no socket no `held` arrives: the leases wait for it (`resumed`).
|
|
1084
|
-
if (!this.o.me.online) return
|
|
1085
|
-
const now = performance.now()
|
|
1086
|
-
const back: Job[] = []
|
|
1087
|
-
for (const a of this.workers.values())
|
|
1088
|
-
for (const j of [...a.busy.values()])
|
|
1089
|
-
if (j.leaseUntil !== undefined && j.leaseUntil < now) {
|
|
1090
|
-
const again = this.retry(a, j, 'lease expired')
|
|
1091
|
-
if (again !== undefined) back.push(again)
|
|
1092
|
-
}
|
|
1093
|
-
if (back.length === 0) return
|
|
1094
|
-
this.queue.unshift(...back)
|
|
1095
|
-
this.o.faulted?.()
|
|
1096
|
-
if (this.spawnedAt.size > 0) this.stranded(`a task's lease ended and no worker is left`)
|
|
1097
|
-
this.pumpSoon()
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
1164
|
private post(a: Worker, m: ToWorker): void {
|
|
1101
1165
|
a.chain = a.chain.then(() => this.o.me.send(a.id, m)).catch(this.unsent(m.type, a.label))
|
|
1102
1166
|
}
|
|
@@ -1118,16 +1182,19 @@ export class Orchestrator {
|
|
|
1118
1182
|
}
|
|
1119
1183
|
|
|
1120
1184
|
/** Worker `a` ran `task` and saved it under `key`: it is done there, and done here once restored. */
|
|
1121
|
-
private async finish(
|
|
1185
|
+
private async finish(
|
|
1186
|
+
a: Worker,
|
|
1187
|
+
task: string,
|
|
1188
|
+
key: string,
|
|
1189
|
+
head: WiredResult,
|
|
1190
|
+
endedAt: number | undefined,
|
|
1191
|
+
): Promise<void> {
|
|
1122
1192
|
const job = a.busy.get(task)
|
|
1123
1193
|
if (job === undefined) return
|
|
1124
1194
|
// A dependency's remote copy did not read back there (a truncated
|
|
1125
1195
|
// upload): sent again from this machine, the task runs again. Taken as
|
|
1126
1196
|
// the task's failure, it failed every run, the remote saying it held the
|
|
1127
1197
|
// key (2026-10-25).
|
|
1128
|
-
// Ended there, it is in no `held` again: a lease running on through a
|
|
1129
|
-
// slow resend called its worker faulty and gave the task to another.
|
|
1130
|
-
delete job.leaseUntil
|
|
1131
1198
|
if (head.unread !== undefined) {
|
|
1132
1199
|
const sent = await this.resend(job, head.unread)
|
|
1133
1200
|
// Its worker was lost meanwhile: the task went back to the queue.
|
|
@@ -1139,13 +1206,13 @@ export class Orchestrator {
|
|
|
1139
1206
|
a.started.delete(task)
|
|
1140
1207
|
const again = this.retry(a, job, `could not read ${head.unread.join(', ')}`)
|
|
1141
1208
|
if (again !== undefined) {
|
|
1142
|
-
this.
|
|
1209
|
+
this.enqueue([again], true)
|
|
1143
1210
|
this.pumpSoon()
|
|
1144
1211
|
}
|
|
1145
1212
|
return
|
|
1146
1213
|
}
|
|
1147
1214
|
}
|
|
1148
|
-
const endAt = performance.now()
|
|
1215
|
+
const endAt = endedAt ?? performance.now()
|
|
1149
1216
|
this.traceTask(a, job, head, endAt)
|
|
1150
1217
|
a.busy.delete(task)
|
|
1151
1218
|
this.unhold(a, job)
|
|
@@ -1154,7 +1221,15 @@ export class Orchestrator {
|
|
|
1154
1221
|
a.done.push(task)
|
|
1155
1222
|
a.usage.record(head, job.sentAt)
|
|
1156
1223
|
const share = shareOf(head.cpuShare)
|
|
1157
|
-
|
|
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
|
+
)
|
|
1158
1233
|
this.took.set(task, {
|
|
1159
1234
|
ms: held,
|
|
1160
1235
|
cores:
|
|
@@ -1180,11 +1255,9 @@ export class Orchestrator {
|
|
|
1180
1255
|
this.needMs += Math.min(job.need.cores, a.cores) * ms
|
|
1181
1256
|
}
|
|
1182
1257
|
this.savedAs.set(task, key)
|
|
1258
|
+
this.ranOn.set(task, a.id)
|
|
1183
1259
|
const node = this.o.nodes()?.get(task)
|
|
1184
|
-
|
|
1185
|
-
// alone, as an uncached task's are, and its output came with its `end`.
|
|
1186
|
-
const unshared = head.unshared === true
|
|
1187
|
-
if (head.exitCode === 0 && (unshared || (node !== undefined && leaves(node))))
|
|
1260
|
+
if (head.exitCode === 0 && node !== undefined && leaves(node))
|
|
1188
1261
|
this.kept.set(task, { worker: a.id, req: job.req, need: job.need })
|
|
1189
1262
|
this.pumpSoon()
|
|
1190
1263
|
if (job.again !== undefined) return job.resolve(result(head, []))
|
|
@@ -1202,17 +1275,14 @@ export class Orchestrator {
|
|
|
1202
1275
|
const deferred = job.req.remoteOnly !== true && job.req.download === 'deferred'
|
|
1203
1276
|
const read = () =>
|
|
1204
1277
|
job.req.remoteOnly === true || deferred ? this.o.artifacts.output(key) : restore()
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
job.reject(err)
|
|
1214
|
-
return undefined
|
|
1215
|
-
})
|
|
1278
|
+
// A failed download reads as a miss: asked once more, since one stalled
|
|
1279
|
+
// download failed a CI run whose upload had landed (main, 2026-10-28).
|
|
1280
|
+
const log = await read()
|
|
1281
|
+
.then((got) => got ?? read())
|
|
1282
|
+
.catch((err: unknown) => {
|
|
1283
|
+
job.reject(err)
|
|
1284
|
+
return undefined
|
|
1285
|
+
})
|
|
1216
1286
|
this.trace.slice({
|
|
1217
1287
|
track: ORCHESTRATOR,
|
|
1218
1288
|
name: task,
|
|
@@ -1224,14 +1294,14 @@ export class Orchestrator {
|
|
|
1224
1294
|
if (log === null) {
|
|
1225
1295
|
job.reject(
|
|
1226
1296
|
new Error(
|
|
1227
|
-
`${task}: ${a.label} ran it, and no cache this machine reads gave its entry, asked twice: a download failed (warned above),
|
|
1297
|
+
`${task}: ${a.label} ran it, and no cache this machine reads gave its entry, asked twice: a download failed (warned above), or the workers and this machine share no remote cache the workers write`,
|
|
1228
1298
|
),
|
|
1229
1299
|
)
|
|
1230
1300
|
return
|
|
1231
1301
|
}
|
|
1232
1302
|
// An upstream's file the task removed there is gone here too: a restore
|
|
1233
1303
|
// lays files, never removes one, and this disk is what core saves from.
|
|
1234
|
-
if (head.removed !== undefined && job.req.remoteOnly !== true && !deferred
|
|
1304
|
+
if (head.removed !== undefined && job.req.remoteOnly !== true && !deferred) {
|
|
1235
1305
|
const ours = names(
|
|
1236
1306
|
path.relative(this.o.root, node?.projectDir ?? job.req.cwd),
|
|
1237
1307
|
job.req.outputs,
|
|
@@ -1267,29 +1337,17 @@ export class Orchestrator {
|
|
|
1267
1337
|
for (const c of log) (c.err ? job.req.onStderr : job.req.onStdout)(c.text)
|
|
1268
1338
|
job.resolve({
|
|
1269
1339
|
...result(head, log),
|
|
1270
|
-
...(
|
|
1340
|
+
...(deferred
|
|
1271
1341
|
? {
|
|
1272
1342
|
outputs: {
|
|
1273
1343
|
kind: 'deferred' as const,
|
|
1274
|
-
materialize: () =>
|
|
1275
|
-
|
|
1276
|
-
new
|
|
1277
|
-
|
|
1278
|
-
),
|
|
1279
|
-
),
|
|
1344
|
+
materialize: async () => {
|
|
1345
|
+
if ((await restore()) === null)
|
|
1346
|
+
throw new Error(`${task}'s entry ${key} is in no cache this machine reads`)
|
|
1347
|
+
},
|
|
1280
1348
|
},
|
|
1281
1349
|
}
|
|
1282
|
-
:
|
|
1283
|
-
? {
|
|
1284
|
-
outputs: {
|
|
1285
|
-
kind: 'deferred' as const,
|
|
1286
|
-
materialize: async () => {
|
|
1287
|
-
if ((await restore()) === null)
|
|
1288
|
-
throw new Error(`${task}'s entry ${key} is in no cache this machine reads`)
|
|
1289
|
-
},
|
|
1290
|
-
},
|
|
1291
|
-
}
|
|
1292
|
-
: {}),
|
|
1350
|
+
: {}),
|
|
1293
1351
|
where: a.label,
|
|
1294
1352
|
machine: {
|
|
1295
1353
|
cores: a.reported.cores,
|
|
@@ -1374,6 +1432,7 @@ export class Orchestrator {
|
|
|
1374
1432
|
if (info.id !== undefined && this.late.delete(info.id)) {
|
|
1375
1433
|
this.cancelled.delete(info.id)
|
|
1376
1434
|
this.unstarted.delete(info.id)
|
|
1435
|
+
this.o.kept?.(info.id)
|
|
1377
1436
|
}
|
|
1378
1437
|
if (info.id !== undefined && this.cancelled.has(info.id)) {
|
|
1379
1438
|
this.turnedAway.add(info.id)
|
|
@@ -1422,7 +1481,9 @@ export class Orchestrator {
|
|
|
1422
1481
|
label,
|
|
1423
1482
|
busy: new Map(),
|
|
1424
1483
|
started: new Set(),
|
|
1484
|
+
batches: 0,
|
|
1425
1485
|
has: new Set(),
|
|
1486
|
+
sentDeps: new Map(),
|
|
1426
1487
|
// Its name first, before any batch.
|
|
1427
1488
|
chain: this.cache()
|
|
1428
1489
|
.then((cache) =>
|
|
@@ -1459,7 +1520,7 @@ export class Orchestrator {
|
|
|
1459
1520
|
const again = this.retry(a, j, 'lost')
|
|
1460
1521
|
if (again !== undefined) back.push(again)
|
|
1461
1522
|
}
|
|
1462
|
-
this.
|
|
1523
|
+
this.enqueue(back, true)
|
|
1463
1524
|
this.o.lost?.(a.label)
|
|
1464
1525
|
// Workers that join on their own may yet join; ones this run starts
|
|
1465
1526
|
// are all it gets.
|
|
@@ -1487,67 +1548,99 @@ export class Orchestrator {
|
|
|
1487
1548
|
}
|
|
1488
1549
|
const a = this.workers.get(from)
|
|
1489
1550
|
if (a === undefined) continue
|
|
1490
|
-
const
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
this.
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1551
|
+
const said = body as ToOrchestrator
|
|
1552
|
+
const frame = said.type === 'together' ? said.messages : [said]
|
|
1553
|
+
// A worker's times, read against its clock as this frame left it:
|
|
1554
|
+
// no two machines' clocks agree.
|
|
1555
|
+
const now = performance.now()
|
|
1556
|
+
let at: number | undefined
|
|
1557
|
+
for (const m of frame) if ('load' in m && m.load?.at !== undefined) at = Number(m.load.at)
|
|
1558
|
+
const here = (t: unknown): number | undefined =>
|
|
1559
|
+
at === undefined || typeof t !== 'number' || !Number.isFinite(at - t)
|
|
1560
|
+
? undefined
|
|
1561
|
+
: Math.min(now, now - (at - t))
|
|
1562
|
+
for (const m of frame) {
|
|
1563
|
+
if ('load' in m && m.load !== undefined) this.heard(a, m.load)
|
|
1564
|
+
if (m.type !== 'report') this.movedAt = performance.now()
|
|
1565
|
+
if (m.type === 'report') {
|
|
1566
|
+
// Every task of the batches it read that has not ended is in its
|
|
1567
|
+
// report: one missing it no longer runs, and goes back at once.
|
|
1568
|
+
const running = new Set(m.running)
|
|
1569
|
+
const read = Number(m.batch) || 0
|
|
1570
|
+
const back: Job[] = []
|
|
1571
|
+
for (const [t, j] of [...a.busy])
|
|
1572
|
+
if (
|
|
1573
|
+
a.started.has(t) &&
|
|
1574
|
+
j.ended !== true &&
|
|
1575
|
+
(j.batch ?? 0) <= read &&
|
|
1576
|
+
!running.has(t)
|
|
1577
|
+
) {
|
|
1578
|
+
const again = this.retry(a, j, 'not in its report')
|
|
1579
|
+
if (again !== undefined) back.push(again)
|
|
1580
|
+
}
|
|
1581
|
+
if (back.length > 0) {
|
|
1582
|
+
this.enqueue(back, true)
|
|
1583
|
+
this.o.faulted?.()
|
|
1584
|
+
if (this.spawnedAt.size > 0)
|
|
1585
|
+
this.stranded(`${a.label} no longer runs a task it began and no worker is left`)
|
|
1586
|
+
}
|
|
1587
|
+
this.pumpSoon()
|
|
1588
|
+
} else if (m.type === 'ran') {
|
|
1589
|
+
// Its command ended: its cores are the next task's while it saves.
|
|
1590
|
+
const j = a.busy.get(m.task)
|
|
1591
|
+
if (j === undefined) continue
|
|
1592
|
+
this.begun(a, m.task)
|
|
1593
|
+
this.unhold(a, j)
|
|
1594
|
+
// Its worker still says it holds it while it saves.
|
|
1595
|
+
j.startedAt = here(m.startedAt) ?? j.startedAt ?? now
|
|
1596
|
+
j.ranAt = here(m.ranAt) ?? now
|
|
1597
|
+
this.pumpSoon()
|
|
1598
|
+
} else if (m.type === 'fault') {
|
|
1599
|
+
const j = a.busy.get(m.task)
|
|
1600
|
+
if (j === undefined) continue
|
|
1601
|
+
// It began it whether or not its `start` came first.
|
|
1602
|
+
this.begun(a, m.task)
|
|
1603
|
+
if (this.retry(a, j, m.reason) !== undefined) this.enqueue([j], true)
|
|
1604
|
+
if (a.faulty) this.o.faulted?.()
|
|
1605
|
+
if (a.faulty && this.spawnedAt.size > 0)
|
|
1606
|
+
this.stranded(
|
|
1607
|
+
`${a.label} could not run ${m.task} (${m.reason}) and no worker is left`,
|
|
1608
|
+
)
|
|
1609
|
+
this.pumpSoon()
|
|
1610
|
+
} else if (m.type === 'part') {
|
|
1611
|
+
a.usage.messagesOut++
|
|
1612
|
+
a.usage.bytesOut += bytesOf(m)
|
|
1613
|
+
const k = `${from}\0${m.task}`
|
|
1614
|
+
const held = this.parts.get(k) ?? {}
|
|
1615
|
+
for (const list of PART_LISTS) {
|
|
1616
|
+
const items = m[list] as readonly unknown[] | undefined
|
|
1617
|
+
if (items !== undefined) held[list] = [...(held[list] ?? []), ...items]
|
|
1618
|
+
}
|
|
1619
|
+
this.parts.set(k, held)
|
|
1620
|
+
} else if (m.type === 'end') {
|
|
1621
|
+
a.usage.messagesOut++
|
|
1622
|
+
a.usage.bytesOut += bytesOf(m)
|
|
1623
|
+
const k = `${from}\0${m.task}`
|
|
1624
|
+
const held = this.parts.get(k)
|
|
1625
|
+
this.parts.delete(k)
|
|
1626
|
+
const result: Record<string, unknown> = { ...m.result }
|
|
1627
|
+
for (const list of PART_LISTS) {
|
|
1628
|
+
const items = m.result[list] as readonly unknown[] | undefined
|
|
1629
|
+
if (held?.[list] !== undefined) result[list] = [...held[list], ...(items ?? [])]
|
|
1630
|
+
}
|
|
1631
|
+
// Not awaited: one task's restore holds no other message.
|
|
1632
|
+
const j = a.busy.get(m.task)
|
|
1633
|
+
if (j !== undefined) {
|
|
1634
|
+
j.ended = true
|
|
1635
|
+
const began = here(m.startedAt)
|
|
1636
|
+
const ran = here(m.ranAt)
|
|
1637
|
+
if (began !== undefined) j.startedAt = began
|
|
1638
|
+
if (ran !== undefined) j.ranAt = ran
|
|
1639
|
+
}
|
|
1640
|
+
void this.finish(a, m.task, m.key, result as WiredResult, here(m.endedAt))
|
|
1542
1641
|
}
|
|
1543
|
-
// Not awaited: one task's restore holds no other message.
|
|
1544
|
-
void this.finish(a, m.task, m.key, result as WiredResult)
|
|
1545
1642
|
}
|
|
1546
1643
|
}
|
|
1547
|
-
// After the batch: a `held` in it renews its lease first. Expired
|
|
1548
|
-
// before, a stalled loop's waiting `held` came too late and the task
|
|
1549
|
-
// ran again on another worker (2026-10-27).
|
|
1550
|
-
this.expire()
|
|
1551
1644
|
this.stall()
|
|
1552
1645
|
}
|
|
1553
1646
|
}
|
|
@@ -1556,7 +1649,7 @@ export class Orchestrator {
|
|
|
1556
1649
|
where(taskId: string): string | null | undefined {
|
|
1557
1650
|
for (const a of this.workers.values())
|
|
1558
1651
|
if (a.busy.has(taskId)) return a.started.has(taskId) ? a.label : null
|
|
1559
|
-
return this.
|
|
1652
|
+
return this.waiting.has(taskId) ? null : undefined
|
|
1560
1653
|
}
|
|
1561
1654
|
|
|
1562
1655
|
/**
|
|
@@ -1729,7 +1822,8 @@ export class Orchestrator {
|
|
|
1729
1822
|
id: j.req.taskId,
|
|
1730
1823
|
cores: Math.min(a.cores, Math.max((j.held?.cores ?? 0) * over, j.need.ask?.cores ?? 0)),
|
|
1731
1824
|
memory: Math.max(j.held?.memory ?? 0, Math.min(a.memory, j.need.ask?.memory ?? 0)),
|
|
1732
|
-
|
|
1825
|
+
// Below 0: past its time, which the plan weighs (`decide`).
|
|
1826
|
+
endsIn: (ms(j.req.taskId) ?? 0) - (now - j.startedAt!),
|
|
1733
1827
|
})),
|
|
1734
1828
|
})
|
|
1735
1829
|
}
|
|
@@ -1877,7 +1971,11 @@ export class Orchestrator {
|
|
|
1877
1971
|
private fail(reason: string): void {
|
|
1878
1972
|
for (const a of this.workers.values())
|
|
1879
1973
|
for (const j of a.busy.values()) j.reject(new Error(reason))
|
|
1880
|
-
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()
|
|
1881
1979
|
this.workers.clear()
|
|
1882
1980
|
this.lost = reason
|
|
1883
1981
|
}
|
|
@@ -1901,13 +1999,8 @@ export class Orchestrator {
|
|
|
1901
1999
|
* waits on its deps through those no worker ran (groups, hits).
|
|
1902
2000
|
*/
|
|
1903
2001
|
idealMs(depsOf: (id: string) => readonly string[], sizes: readonly Size[]): number | undefined {
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
const warm = new Map<string | undefined, number>()
|
|
1907
|
-
for (const a of all)
|
|
1908
|
-
if (a.warmUpMs !== undefined && !(warm.get(a.platform)! <= a.warmUpMs))
|
|
1909
|
-
warm.set(a.platform, a.warmUpMs)
|
|
1910
|
-
const least = this.warmUpMs() ?? 0
|
|
2002
|
+
const r = this.replay(depsOf)
|
|
2003
|
+
if (r === undefined) return undefined
|
|
1911
2004
|
const pool: PlanWorker[] = (
|
|
1912
2005
|
sizes.length > 0
|
|
1913
2006
|
? sizes.flatMap((s) =>
|
|
@@ -1917,14 +2010,104 @@ export class Orchestrator {
|
|
|
1917
2010
|
platform: s.platform,
|
|
1918
2011
|
})),
|
|
1919
2012
|
)
|
|
1920
|
-
:
|
|
2013
|
+
: [...this.workers.values(), ...this.gone].map((a) => ({
|
|
2014
|
+
cores: a.cores,
|
|
2015
|
+
memory: a.memory,
|
|
2016
|
+
platform: a.platform,
|
|
2017
|
+
}))
|
|
1921
2018
|
).map((w, i) => ({
|
|
1922
2019
|
...w,
|
|
1923
2020
|
id: `ideal#${i}`,
|
|
1924
|
-
at: warm
|
|
2021
|
+
at: r.warm(w.platform),
|
|
1925
2022
|
running: [],
|
|
1926
2023
|
idle: false,
|
|
1927
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
|
|
1928
2111
|
const seen = new Map<string, string[]>()
|
|
1929
2112
|
const ran = (id: string): string[] => {
|
|
1930
2113
|
const known = seen.get(id)
|
|
@@ -1934,10 +2117,11 @@ export class Orchestrator {
|
|
|
1934
2117
|
seen.set(id, out)
|
|
1935
2118
|
return out
|
|
1936
2119
|
}
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
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
|
+
}
|
|
1941
2125
|
}
|
|
1942
2126
|
|
|
1943
2127
|
/**
|
|
@@ -1996,7 +2180,10 @@ export class Orchestrator {
|
|
|
1996
2180
|
* each, the cores and memory its size declares and its machine reports;
|
|
1997
2181
|
* and `ideal`, the run's `idealMs`, asked once it ended.
|
|
1998
2182
|
*/
|
|
1999
|
-
footer(
|
|
2183
|
+
footer(
|
|
2184
|
+
ideal?: () => number | undefined,
|
|
2185
|
+
advice?: () => readonly WorkerAdvice[],
|
|
2186
|
+
): [string, ui.Inline][] {
|
|
2000
2187
|
// Over with none, it says so: core's own `N workers` (this machine's
|
|
2001
2188
|
// pool) read as the run's when no task needed one (2026-10-25).
|
|
2002
2189
|
if (this.spawnedAt.size === 0 && this.workers.size === 0 && this.gone.length === 0)
|
|
@@ -2127,6 +2314,17 @@ export class Orchestrator {
|
|
|
2127
2314
|
),
|
|
2128
2315
|
] as [string, ui.Inline],
|
|
2129
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
|
+
),
|
|
2130
2328
|
...(this.unmeasured.length === 0
|
|
2131
2329
|
? []
|
|
2132
2330
|
: [
|
|
@@ -2155,7 +2353,7 @@ export class Orchestrator {
|
|
|
2155
2353
|
}
|
|
2156
2354
|
|
|
2157
2355
|
function result(r: WiredResult, log: readonly CapturedChunk[]): ExecuteResult {
|
|
2158
|
-
const { cpuShare: _, removed: __, unread: ___,
|
|
2356
|
+
const { cpuShare: _, removed: __, unread: ___, ...rest } = r
|
|
2159
2357
|
const violations = (r.violations ?? []).map((v) => ({ ...v, timestamp: new Date(v.timestamp) }))
|
|
2160
2358
|
const of = (err: boolean) =>
|
|
2161
2359
|
log
|
|
@@ -2165,18 +2363,6 @@ function result(r: WiredResult, log: readonly CapturedChunk[]): ExecuteResult {
|
|
|
2165
2363
|
return { ...rest, stdout: of(false), stderr: of(true), violations, outputs: { kind: 'disk' } }
|
|
2166
2364
|
}
|
|
2167
2365
|
|
|
2168
|
-
/** A worker's `output`, read at the boundary: its chunks of text. */
|
|
2169
|
-
function wiredLog(output: unknown): CapturedChunk[] {
|
|
2170
|
-
return Array.isArray(output)
|
|
2171
|
-
? output
|
|
2172
|
-
.filter(
|
|
2173
|
-
(c): c is CapturedChunk =>
|
|
2174
|
-
typeof c === 'object' && c !== null && typeof c.text === 'string',
|
|
2175
|
-
)
|
|
2176
|
-
.map((c) => ({ text: c.text, err: c.err === true }))
|
|
2177
|
-
: []
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
2366
|
/**
|
|
2181
2367
|
* A worker's `cpuShare`, read at the boundary: in (0, 1], else 1. A twentieth
|
|
2182
2368
|
* at least: a share that small is a misread, and dividing by it would book
|
|
@@ -2204,15 +2390,28 @@ export function nestedIn(
|
|
|
2204
2390
|
): string[] {
|
|
2205
2391
|
let of = nested.get(nodes)
|
|
2206
2392
|
if (of === undefined) {
|
|
2207
|
-
of = {
|
|
2393
|
+
of = {
|
|
2394
|
+
dirs: [...new Set([...nodes.values()].map((n) => n.projectDir))].sort(),
|
|
2395
|
+
by: new Map(),
|
|
2396
|
+
}
|
|
2208
2397
|
nested.set(nodes, of)
|
|
2209
2398
|
}
|
|
2210
2399
|
let out = of.by.get(dir)
|
|
2211
2400
|
if (out === undefined) {
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
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()
|
|
2216
2415
|
of.by.set(dir, out)
|
|
2217
2416
|
}
|
|
2218
2417
|
return [...out]
|
|
@@ -2264,7 +2463,7 @@ function sandboxConfigs(nodes: ReadonlyMap<string, TaskNode> | undefined): Sandb
|
|
|
2264
2463
|
* pinning every dependant to its worker held a run's shards on one.
|
|
2265
2464
|
*/
|
|
2266
2465
|
function leaves(node: TaskNode): boolean {
|
|
2267
|
-
if (node.config.cache
|
|
2466
|
+
if (node.config.cache === true) return false
|
|
2268
2467
|
const sandbox = node.config.exec?.sandbox
|
|
2269
2468
|
return sandbox === undefined || (sandbox.allow?.write?.length ?? 0) > 0
|
|
2270
2469
|
}
|