@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.
@@ -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 { simulate, type PlanTask, type PlanWorker, type Size, type WarmUp } from './planner.js'
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 { assign, ordered, type Need } from './dispatch.js'
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 { order: unknown }>(orders: readonly T[], max: number): T[][] {
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 = bytesOf(o.order)
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
- private readonly queue: Job[] = []
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
- const nodes = this.o.nodes()
384
- const restored = [...upstream(nodes, req.taskId)].flatMap((id) => {
385
- const n = nodes?.get(id)
386
- const outs = n?.config.cache?.outputs
387
- return n === undefined || outs === undefined
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(req.taskId)
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.queue.push(job)
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
- const at = this.queue.indexOf(job)
503
- if (at >= 0) {
504
- this.queue.splice(at, 1)
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 = new Map(waiting.map((j) => [j.req.taskId, j]))
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 asks = []
559
- const placed: Job[] = []
560
- // The least any ask needs: once no worker takes it, the pass ends. Each
561
- // pump read every queued task, 8000 of them a pump (2026-10-25).
562
- const floor = { cores: Infinity, memory: Infinity }
563
- for (const job of queue) {
564
- const up =
565
- job.up ??
566
- (nodes?.has(job.req.taskId) === true
567
- ? (job.up = upstream(nodes, job.req.taskId))
568
- : upstream(nodes, job.req.taskId))
569
- const pin = this.pin(job, up)
570
- if (pin === 'done') continue
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)?.a
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.queue.unshift({
697
- req: k.req,
698
- need: where.platform === undefined ? k.need : { ...k.need, platform: where.platform },
699
- queuedAt: performance.now(),
700
- pinnedAt: performance.now(),
701
- again: {
702
- ...(where.only === undefined ? {} : { only: where.only }),
703
- ...(where.avoid === undefined ? {} : { avoid: where.avoid }),
704
- },
705
- resolve: (r) =>
706
- done(
707
- r.exitCode === 0 ? undefined : `${id} ran again for it and failed (exit ${r.exitCode})`,
708
- ),
709
- reject: (err) =>
710
- done(
711
- `${id} ran again for it and failed: ${err instanceof Error ? err.message : String(err)}`,
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(id: string): Promise<void> {
819
- const nodes = this.o.nodes()
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 upstream(nodes, id)) {
822
- if (this.savedAs.has(d) || nodes?.get(d)?.config.cache?.outputs === undefined) continue
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 n = nodes?.get(id)
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 (n === undefined || outs === undefined || key === undefined) continue
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
- return {
888
- job,
889
- order: {
890
- task: req.taskId,
891
- // Unsaved: under the run's own key, as an uncached task's.
892
- ...(req.cacheKey === undefined || req.unsaved === true ? {} : { key: req.cacheKey }),
893
- runKey: runKey(this.o.run, req.taskId, this.sends(req.taskId)),
894
- // A held key keeps its entry, and restored here it replaced the
895
- // forced run's fresh bytes with the ones it bypassed (2026-10-25).
896
- ...(req.refresh === true ? { fresh: true as const } : {}),
897
- // A HEAD per upload was the workers' only remote probe: the run
898
- // already knows the key is in no cache (2026-10-25).
899
- ...(req.refresh !== true && this.missed.has(req.taskId) ? { absent: true as const } : {}),
900
- deps,
901
- nested: node === undefined ? [] : nestedIn(nodes!, node.projectDir, this.o.root),
902
- command: withForwardArgs(req.command, req.forwardArgs),
903
- cwd: path.relative(this.o.root, req.cwd),
904
- project: node === undefined ? '' : path.relative(this.o.root, node.projectDir),
905
- env,
906
- ...(node?.config.exec?.env?.secret === undefined
907
- ? {}
908
- : { secret: [...node.config.exec.env.secret] }),
909
- ...(req.timeoutMs === undefined ? {} : { timeoutMs: req.timeoutMs }),
910
- outputs: {
911
- files: [...req.outputs.files],
912
- workspaceFiles: [...req.outputs.workspaceFiles],
913
- },
914
- cores: job.held?.cores ?? 1,
915
- memory: job.held?.memory ?? 0,
916
- ...(req.sandbox === undefined || node?.config.exec?.sandbox === undefined
917
- ? {}
918
- : {
919
- sandbox: {
920
- config: node.config.exec.sandbox,
921
- ...(req.sandbox.keyed === undefined
922
- ? {}
923
- : { keyed: req.sandbox.keyed.map((d) => path.relative(this.o.root, d)) }),
924
- },
925
- }),
926
- ...runtimeOrders(req),
927
- ...(req.servers === undefined || req.servers.length === 0
928
- ? {}
929
- : {
930
- servers: req.servers.map((s) => ({
931
- task: s.taskId,
932
- command: s.command,
933
- cwd: path.relative(this.o.root, s.cwd),
934
- env: defined(s.env, {}),
935
- ...(s.readyWhen === undefined ? {} : { readyWhen: s.readyWhen }),
936
- ...(s.timeoutMs === undefined ? {} : { timeoutMs: s.timeoutMs }),
937
- ...(s.sandbox === undefined
938
- ? {}
939
- : {
940
- sandbox: {
941
- config: s.sandbox.config,
942
- nested: s.sandbox.nested.map((d) => path.relative(this.o.root, d)),
943
- },
944
- }),
945
- })),
946
- }),
947
- } as RunOrder,
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
- for (const [i, part] of parts(orders, BATCH_BYTES).entries()) {
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: RunOrder[] = []
962
- for (const { order } of part) {
963
- if (bytesOf(order) <= BATCH_BYTES) {
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
- tasks.push({ ...order, deps: [] })
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.usage.bytesIn += bytesOf(batch)
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.queue.unshift(again)
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
- const held = Math.max(1, job.startedAt === undefined ? head.durationMs : endAt - job.startedAt)
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.queue.unshift(...back)
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.queue.unshift(...back)
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.queue.unshift(j)
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.queue.some((j) => j.req.taskId === taskId) ? null : undefined
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
- endsIn: Math.max(0, (ms(j.req.taskId) ?? 0) - (now - j.startedAt!)),
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.queue.splice(0)) j.reject(new Error(reason))
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
- if (this.took.size === 0) return undefined
1903
- const all = [...this.workers.values(), ...this.gone]
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
- : all.map((a) => ({ cores: a.cores, memory: a.memory, platform: a.platform }))
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.get(w.platform) ?? least,
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
- const tasks: PlanTask[] = [...this.took].map(([id, t]) => ({ id, ...t, deps: ran(id) }))
1936
- const end = simulate(tasks, pool)
1937
- if (!Number.isFinite(end)) return undefined
1938
- return (this.probe?.at ?? this.asks[0]?.at ?? 0) + end
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(ideal?: () => number | undefined): [string, ui.Inline][] {
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 = { dirs: [...new Set([...nodes.values()].map((n) => n.projectDir))], by: new Map() }
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
- out = of.dirs
2199
- .filter((d) => d.startsWith(dir + path.sep))
2200
- .map((d) => path.relative(root, d))
2201
- .sort()
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 !== undefined) return false
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
  }