@vzn/vx-dte 0.0.0-stage → 0.0.649
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +924 -2
- package/index.ts +4 -0
- package/package.json +42 -4
- package/src/cpu-account.ts +134 -0
- package/src/dispatch.ts +268 -0
- package/src/docker.ts +156 -0
- package/src/forecast.ts +68 -0
- package/src/github.ts +523 -0
- package/src/gitlab.ts +130 -0
- package/src/index.ts +12 -0
- package/src/init.ts +522 -0
- package/src/local.ts +256 -0
- package/src/messages.ts +219 -0
- package/src/noise.ts +248 -0
- package/src/orchestrator.ts +2359 -0
- package/src/outputs.ts +117 -0
- package/src/planner.ts +988 -0
- package/src/plugin.ts +1020 -0
- package/src/printable.ts +11 -0
- package/src/seal.ts +509 -0
- package/src/strategy.ts +50 -0
- package/src/trace.ts +288 -0
- package/src/tree.ts +72 -0
- package/src/usage.ts +135 -0
- package/src/warm-ups.ts +85 -0
- package/src/wire.ts +1060 -0
- package/src/worker.ts +1038 -0
|
@@ -0,0 +1,2359 @@
|
|
|
1
|
+
// The orchestrator's half of a distributed run: the `vx run` the user
|
|
2
|
+
// started. It runs no task itself (only hits restore here) and hands
|
|
3
|
+
// every task to the workers online, critical path first (the order core asks in):
|
|
4
|
+
// each task to a worker it fits now, by the load that worker last reported
|
|
5
|
+
// and what was sent since, preferring the worker that already holds the
|
|
6
|
+
// task's dependencies; none waits in a worker's queue. No file travels through the relay: an
|
|
7
|
+
// worker restores a dependency's files from the cache by the key its order
|
|
8
|
+
// names and saves its own under the task's key, and this machine restores
|
|
9
|
+
// them by that key before the task ends, so the run saves and reports it
|
|
10
|
+
// as its own.
|
|
11
|
+
|
|
12
|
+
import { realpathSync } from 'node:fs'
|
|
13
|
+
import { realpath, rm } from 'node:fs/promises'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import {
|
|
16
|
+
hostPlatform,
|
|
17
|
+
ui,
|
|
18
|
+
UserError,
|
|
19
|
+
withForwardArgs,
|
|
20
|
+
type CacheFacts,
|
|
21
|
+
type CapturedChunk,
|
|
22
|
+
type ExecuteRequest,
|
|
23
|
+
type ExecuteResult,
|
|
24
|
+
type MachineReport,
|
|
25
|
+
type SandboxConfig,
|
|
26
|
+
type TaskArtifacts,
|
|
27
|
+
type TaskNode,
|
|
28
|
+
} from '@vzn/vx'
|
|
29
|
+
import {
|
|
30
|
+
bytesOf,
|
|
31
|
+
HELD_EVERY,
|
|
32
|
+
HOST_ENV,
|
|
33
|
+
PART_LISTS,
|
|
34
|
+
type PartList,
|
|
35
|
+
type WorkerInfo,
|
|
36
|
+
type DepOrder,
|
|
37
|
+
type RunOrder,
|
|
38
|
+
type RuntimeOrder,
|
|
39
|
+
type ToWorker,
|
|
40
|
+
type Load,
|
|
41
|
+
type ToOrchestrator,
|
|
42
|
+
type WiredResult,
|
|
43
|
+
} from './messages.js'
|
|
44
|
+
import { simulate, type PlanTask, type PlanWorker, type Size, type WarmUp } from './planner.js'
|
|
45
|
+
import { Usage, type UsageStats } from './usage.js'
|
|
46
|
+
import { assign, ordered, type Need } from './dispatch.js'
|
|
47
|
+
import { DEFAULT_STRATEGY, type Strategy } from './strategy.js'
|
|
48
|
+
import { RelayError, unsent, type Participant } from './wire.js'
|
|
49
|
+
import { ORCHESTRATOR, Trace } from './trace.js'
|
|
50
|
+
import { names } from './outputs.js'
|
|
51
|
+
|
|
52
|
+
/** What a task is expected to hold while it runs, from its history; `Infinity`: a whole worker (one that filled its worker). */
|
|
53
|
+
|
|
54
|
+
interface Job {
|
|
55
|
+
readonly req: ExecuteRequest
|
|
56
|
+
readonly need: Need
|
|
57
|
+
/** The cores and memory it holds on its worker: its need, or the whole worker for one that filled its worker. */
|
|
58
|
+
held?: Need
|
|
59
|
+
readonly resolve: (r: ExecuteResult) => void
|
|
60
|
+
readonly reject: (err: unknown) => void
|
|
61
|
+
worker?: string
|
|
62
|
+
/** When it last joined the run's queue. */
|
|
63
|
+
queuedAt?: number
|
|
64
|
+
/** The cores its worker booked for it. */
|
|
65
|
+
booked?: number
|
|
66
|
+
/** The workers that failed it after beginning it, with why: two fail it. */
|
|
67
|
+
faults?: string[]
|
|
68
|
+
/** Its worker's lease on it ends then unless the worker says it still holds it. */
|
|
69
|
+
leaseUntil?: number
|
|
70
|
+
/** When it was sent to its worker. */
|
|
71
|
+
sentAt?: number
|
|
72
|
+
/** When its worker began it. */
|
|
73
|
+
startedAt?: number
|
|
74
|
+
/** When its worker said its command ended: it saves since. */
|
|
75
|
+
ranAt?: number
|
|
76
|
+
/** When it first waited on the worker holding an upstream's files (`kept`). */
|
|
77
|
+
pinnedAt?: number
|
|
78
|
+
/** Its upstreams already ran again for it: it waits on their worker however long. */
|
|
79
|
+
fellBack?: true
|
|
80
|
+
/** An uncached task run again for a dependant (`kept`): its result is the run's alone. */
|
|
81
|
+
again?: { readonly only?: string; readonly avoid?: string }
|
|
82
|
+
/** Dependency keys it ran again for, sent again from this machine (`resend`). */
|
|
83
|
+
reread?: Set<string>
|
|
84
|
+
/** 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
|
+
up?: Set<string>
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 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
|
+
const PIN_WAIT_MS = 10_000
|
|
90
|
+
|
|
91
|
+
/** No task began or ended this long: the run says what it waits on, each time again. */
|
|
92
|
+
const STALL_MS = 60_000
|
|
93
|
+
|
|
94
|
+
interface Worker {
|
|
95
|
+
readonly id: string
|
|
96
|
+
/** What the run gives it: its machine, held to its declared size. */
|
|
97
|
+
readonly cores: number
|
|
98
|
+
readonly memory: number
|
|
99
|
+
/** What its machine has, as it reported on joining. */
|
|
100
|
+
readonly reported: { readonly cores: number; readonly memory: number }
|
|
101
|
+
/** Its machine's platform, as it reported on joining. */
|
|
102
|
+
readonly platform: string | undefined
|
|
103
|
+
readonly label: string
|
|
104
|
+
/** From its start to its join, when this run started it. */
|
|
105
|
+
readonly warmUpMs: number | undefined
|
|
106
|
+
/** The size it was started as, when this run started it. */
|
|
107
|
+
readonly declared: Declared | undefined
|
|
108
|
+
/** Tasks it finished. */
|
|
109
|
+
ran: number
|
|
110
|
+
/** Their ids, for the summary of a worker that ran few: what held it. */
|
|
111
|
+
readonly done: string[]
|
|
112
|
+
readonly usage: Usage
|
|
113
|
+
/** Sent to it and not finished: running there or in its queue. */
|
|
114
|
+
readonly busy: Map<string, Job>
|
|
115
|
+
/** Of `busy`, the ones it began. */
|
|
116
|
+
readonly started: Set<string>
|
|
117
|
+
usedCores: number
|
|
118
|
+
usedMemory: number
|
|
119
|
+
/** Its machine's use as its last frame said, and when this machine heard it. */
|
|
120
|
+
load?: { cpu: number; memory: number; at: number }
|
|
121
|
+
/** Tasks whose outputs it holds: it ran them, or was sent them. */
|
|
122
|
+
readonly has: Set<string>
|
|
123
|
+
/** Sends to it go out in order: a run's parts before the run. */
|
|
124
|
+
chain: Promise<void>
|
|
125
|
+
/** It was sent the run's sandbox configs. */
|
|
126
|
+
sandboxes: boolean
|
|
127
|
+
/** It failed a task it began: it gets no more. */
|
|
128
|
+
faulty: boolean
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What a worker was started as: its size's `cpu` and `memory` in bytes. */
|
|
132
|
+
export interface Declared {
|
|
133
|
+
readonly size: string
|
|
134
|
+
readonly cpu: number
|
|
135
|
+
readonly memory: number
|
|
136
|
+
/** Its size's platform: a worker reporting another is refused. None: planned as this machine's. */
|
|
137
|
+
readonly platform?: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface OrchestratorOptions {
|
|
141
|
+
readonly me: Participant
|
|
142
|
+
readonly root: string
|
|
143
|
+
/** The tree of the run's tracked files (`tree.ts`). */
|
|
144
|
+
readonly tree: string
|
|
145
|
+
readonly nodes: () => ReadonlyMap<string, TaskNode> | undefined
|
|
146
|
+
readonly need: (taskId: string) => Need
|
|
147
|
+
/** The run's cache, by key: what workers save to and this machine restores from. */
|
|
148
|
+
readonly artifacts: TaskArtifacts
|
|
149
|
+
/** What workers read the cache under (`ExecutorContext.cacheFacts`). */
|
|
150
|
+
readonly cacheFacts: () => Promise<CacheFacts>
|
|
151
|
+
/** The run id, which a task's run key is made of. */
|
|
152
|
+
readonly run: string
|
|
153
|
+
/**
|
|
154
|
+
* Sends of each task under `run`, kept by whoever outlives this run:
|
|
155
|
+
* `vx watch` runs again under one run id, and a send counted from 0
|
|
156
|
+
* again took the last run's run key (2026-10-24).
|
|
157
|
+
*/
|
|
158
|
+
readonly sent?: Map<string, number>
|
|
159
|
+
/** A task's cache key, once the run computed it (a hit's too); undefined when it has none. */
|
|
160
|
+
readonly keyOf: (taskId: string) => string | undefined
|
|
161
|
+
/** Worker `id` (its label) was lost, or never started: room for another. */
|
|
162
|
+
readonly lost?: (id: string) => void
|
|
163
|
+
/** A worker joined: the run plans again. */
|
|
164
|
+
readonly joined?: () => void
|
|
165
|
+
/** Worker `id` did not join within twice its size's first join: given up, for its adapter to stop. */
|
|
166
|
+
readonly overdue?: (id: string) => void
|
|
167
|
+
/** A worker failed a task or its lease: the run plans again, now, so a replacement starts. */
|
|
168
|
+
readonly faulted?: () => void
|
|
169
|
+
/** How much of the run waits on a task (`tailsMs`): the queue places the most first. */
|
|
170
|
+
readonly tail?: (taskId: string) => number
|
|
171
|
+
/** How waiting tasks are ordered and placed (`Strategy`). */
|
|
172
|
+
readonly how?: Strategy
|
|
173
|
+
/** The platform a task naming none runs on (`homeOf`). */
|
|
174
|
+
readonly home?: string | undefined
|
|
175
|
+
/** No task began or ended for `stallMs`: what the run waits on, one line a task. */
|
|
176
|
+
readonly stalled?: (waiting: string) => void
|
|
177
|
+
/** A fault the run works around (a message the relay would not take); default stderr. */
|
|
178
|
+
readonly warn?: (message: string) => void
|
|
179
|
+
/** Default `STALL_MS`. */
|
|
180
|
+
readonly stallMs?: number
|
|
181
|
+
/** How long a task waits on the worker holding its upstream's files (`PIN_WAIT_MS`). */
|
|
182
|
+
readonly pinWaitMs?: number
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Lent tokens are asked again this long before they end: inside the
|
|
187
|
+
* window a remote exchanges a new one (turboCache's is five minutes).
|
|
188
|
+
*/
|
|
189
|
+
const RELEND_MS = 4 * 60_000
|
|
190
|
+
/**
|
|
191
|
+
* A lent token already inside that window is lent again no sooner: one
|
|
192
|
+
* lent as it ended relent at once, in a loop (vx-cache, 2026-10-25).
|
|
193
|
+
*/
|
|
194
|
+
const RELEND_FLOOR_MS = 30_000
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A batch's orders past this go in another: the relay takes a body of
|
|
198
|
+
* 1 MiB at most, and sealing it grows it by a third.
|
|
199
|
+
*/
|
|
200
|
+
const BATCH_BYTES = 512 * 1024
|
|
201
|
+
/** A worker that ran this many tasks or fewer names them in the summary. */
|
|
202
|
+
const FEW = 2
|
|
203
|
+
/** The unmeasured tasks the summary names; the rest it counts. */
|
|
204
|
+
const NAMED = 40
|
|
205
|
+
/** Core-time of history the run's tasks finish before their use reshapes bookings: a few tasks' worth. */
|
|
206
|
+
const LEARN_MS = 60_000
|
|
207
|
+
/** The most a worker's bookings may add up to, in its cores, when tasks use less than their history. */
|
|
208
|
+
const OVERBOOK = 1.5
|
|
209
|
+
/** Failed starts the run replaces before it calls its setup broken. */
|
|
210
|
+
const RESTARTS = 3
|
|
211
|
+
|
|
212
|
+
/** `orders` in runs of at most `max` bytes of JSON; one bigger goes alone. */
|
|
213
|
+
function parts<T extends { order: unknown }>(orders: readonly T[], max: number): T[][] {
|
|
214
|
+
const out: T[][] = []
|
|
215
|
+
let part: T[] = []
|
|
216
|
+
let bytes = 0
|
|
217
|
+
for (const o of orders) {
|
|
218
|
+
const size = bytesOf(o.order)
|
|
219
|
+
if (part.length > 0 && bytes + size > max) {
|
|
220
|
+
out.push(part)
|
|
221
|
+
part = []
|
|
222
|
+
bytes = 0
|
|
223
|
+
}
|
|
224
|
+
part.push(o)
|
|
225
|
+
bytes += size
|
|
226
|
+
}
|
|
227
|
+
if (part.length > 0) out.push(part)
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** `items` in runs of at most `max` bytes of JSON. */
|
|
232
|
+
function chunks<T>(items: readonly T[], max: number): T[][] {
|
|
233
|
+
const out: T[][] = []
|
|
234
|
+
let piece: T[] = []
|
|
235
|
+
let bytes = 0
|
|
236
|
+
for (const item of items) {
|
|
237
|
+
const size = bytesOf(item) + 1
|
|
238
|
+
if (piece.length > 0 && bytes + size > max) {
|
|
239
|
+
out.push(piece)
|
|
240
|
+
piece = []
|
|
241
|
+
bytes = 0
|
|
242
|
+
}
|
|
243
|
+
piece.push(item)
|
|
244
|
+
bytes += size
|
|
245
|
+
}
|
|
246
|
+
if (piece.length > 0) out.push(piece)
|
|
247
|
+
return out
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A task's end when its run stopped it before it began anywhere. */
|
|
251
|
+
const stopped = (): ExecuteResult => result({ exitCode: 130, signal: 'SIGTERM', durationMs: 0 }, [])
|
|
252
|
+
|
|
253
|
+
export class Orchestrator {
|
|
254
|
+
private readonly workers = new Map<string, Worker>()
|
|
255
|
+
/** The run tree's files, workspace-relative, listed when a task first asks. */
|
|
256
|
+
private inTree?: Promise<Tree | undefined>
|
|
257
|
+
private readonly queue: Job[] = []
|
|
258
|
+
/** Where a worker saved each task it ran: its cache key or its run key. */
|
|
259
|
+
private readonly savedAs = new Map<string, string>()
|
|
260
|
+
/** Keys the run's requests named for their dependencies. */
|
|
261
|
+
private readonly upstreamKeys = new Map<string, string>()
|
|
262
|
+
/** How many times each task was sent: its run key is one per send. */
|
|
263
|
+
private readonly sent: Map<string, number>
|
|
264
|
+
/** Each hit's key, once on a remote a worker reads (`TaskArtifacts.share`). */
|
|
265
|
+
private readonly shared = new Map<string, Promise<boolean>>()
|
|
266
|
+
/** Keys a worker could not read, sent again over the remotes' copy: once each, whether one reached a remote. */
|
|
267
|
+
private readonly resent = new Map<string, Promise<boolean>>()
|
|
268
|
+
/** When each worker this run started was asked for, by `WorkerSpawn.index`. */
|
|
269
|
+
private readonly spawnedAt = new Map<string, { at: number; declared: Declared }>()
|
|
270
|
+
private ended = false
|
|
271
|
+
/**
|
|
272
|
+
* How long a worker of a size may take to join: twice the first of its
|
|
273
|
+
* size that did (2026-10-23: "we learn that from first we get, and
|
|
274
|
+
* double its time"). Unknown until one joins, so no wait before.
|
|
275
|
+
*/
|
|
276
|
+
private readonly joinLimitMs = new Map<string, number>()
|
|
277
|
+
private endedAt = 0
|
|
278
|
+
/** A long result's lists, by worker and task, until its `end` comes. */
|
|
279
|
+
private readonly parts = new Map<string, Partial<Record<PartList, unknown[]>>>()
|
|
280
|
+
/** Workers that joined and left, for the footer. */
|
|
281
|
+
private readonly gone: Worker[] = []
|
|
282
|
+
/** Of `gone`, those the run let go (`release`), not lost. */
|
|
283
|
+
private readonly retired = new Set<Worker>()
|
|
284
|
+
/** Workers asked for that never joined, with why. */
|
|
285
|
+
private readonly unstarted = new Map<string, string>()
|
|
286
|
+
/** Started, then not needed before they joined (`release`), or given up as late. */
|
|
287
|
+
private readonly cancelled = new Set<string>()
|
|
288
|
+
/** Of `cancelled`, those given up past their join limit, not let go: taken if they join. */
|
|
289
|
+
private readonly late = new Set<string>()
|
|
290
|
+
/** Of `cancelled`, those that joined anyway and were told to exit. */
|
|
291
|
+
private readonly turnedAway = new Set<string>()
|
|
292
|
+
/** Let go before they joined, and left running by their adapter (`leftRunning`): no plan lets them go again. */
|
|
293
|
+
private readonly unstoppable = new Set<string>()
|
|
294
|
+
/** A failed start was given another worker (`failed`). */
|
|
295
|
+
private restarts = 0
|
|
296
|
+
/**
|
|
297
|
+
* Each uncached task a worker ran: its files are there alone, so its
|
|
298
|
+
* dependants go there (2026-10-25). An uncached task declares no
|
|
299
|
+
* outputs, and a dependant elsewhere did not find them (#4407).
|
|
300
|
+
*/
|
|
301
|
+
private readonly kept = new Map<string, { worker: string; req: ExecuteRequest; need: Need }>()
|
|
302
|
+
/** Of `kept`, those running again for a dependant. */
|
|
303
|
+
private readonly remaking = new Set<string>()
|
|
304
|
+
/** Of `kept`, those that failed running again: their dependants fail, saying why. */
|
|
305
|
+
private readonly unmade = new Map<string, string>()
|
|
306
|
+
/** The tasks core's probe found in no cache (`demand`): a worker uploads theirs asking nothing first. */
|
|
307
|
+
missed: ReadonlySet<string> = new Set()
|
|
308
|
+
private pumping = false
|
|
309
|
+
private joined = 0
|
|
310
|
+
private readonly startedAt = performance.now()
|
|
311
|
+
/** The run in time, for `--profile` (`profile`). */
|
|
312
|
+
readonly trace = new Trace()
|
|
313
|
+
/** When a task last began, ended or faulted, or the run last said it waits. */
|
|
314
|
+
private movedAt = performance.now()
|
|
315
|
+
/** When core's cache probe answered, how many placed tasks it left to run, and how many it found in no cache. */
|
|
316
|
+
private probe?: { at: number; left: number; misses: number }
|
|
317
|
+
/** Each task a worker ran: how long it held the worker, the cores it used meanwhile, its memory, the platform. */
|
|
318
|
+
private readonly took = new Map<
|
|
319
|
+
string,
|
|
320
|
+
{ ms: number; cores: number; memory: number; platform: string | undefined }
|
|
321
|
+
>()
|
|
322
|
+
/** Each time the run asked for workers: when, how many, for which work, ending when. */
|
|
323
|
+
private readonly asks: {
|
|
324
|
+
at: number
|
|
325
|
+
workers: number
|
|
326
|
+
tasks: number
|
|
327
|
+
measured: number
|
|
328
|
+
endsInMs: number
|
|
329
|
+
}[] = []
|
|
330
|
+
/**
|
|
331
|
+
* The tasks the first ask had no measure of, each booking a guess:
|
|
332
|
+
* named, so a run that packed badly says which (main 68b851d, 2026-10-23:
|
|
333
|
+
* 41 of 119, small#7 holding 3.9 cores for a task using 0.3).
|
|
334
|
+
*/
|
|
335
|
+
private unmeasured: readonly string[] = []
|
|
336
|
+
/**
|
|
337
|
+
* Core-time measured tasks used here, and what their history said they
|
|
338
|
+
* would: tasks booked at their history used 82% of it on every Linux
|
|
339
|
+
* worker of cold run 3 (66% of the cores against the sim's 86%,
|
|
340
|
+
* 2026-10-23). A measured task books its need times what they used.
|
|
341
|
+
*/
|
|
342
|
+
private usedMs = 0
|
|
343
|
+
private needMs = 0
|
|
344
|
+
private readonly stop = new AbortController()
|
|
345
|
+
private readonly loop: Promise<void>
|
|
346
|
+
private readonly realRoot: string
|
|
347
|
+
/** What workers read the cache under, asked once and again before its lent tokens end. */
|
|
348
|
+
private facts: Promise<CacheFacts> | undefined
|
|
349
|
+
private relend: ReturnType<typeof setTimeout> | undefined
|
|
350
|
+
/** The plugins any of the run's lends lent a token. */
|
|
351
|
+
private readonly lenders = new Set<string>()
|
|
352
|
+
|
|
353
|
+
constructor(private readonly o: OrchestratorOptions) {
|
|
354
|
+
this.sent = o.sent ?? new Map()
|
|
355
|
+
// A root that is gone fails the run's own reads first.
|
|
356
|
+
this.realRoot = (() => {
|
|
357
|
+
try {
|
|
358
|
+
return realpathSync(o.root)
|
|
359
|
+
// quiet: a root that is gone fails the run's own reads first
|
|
360
|
+
} catch {
|
|
361
|
+
return o.root
|
|
362
|
+
}
|
|
363
|
+
})()
|
|
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
|
+
this.loop = this.listen()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async execute(req: ExecuteRequest): Promise<ExecuteResult> {
|
|
376
|
+
if (this.lost !== undefined) throw new Error(this.lost)
|
|
377
|
+
// A worker runs the run's tree: a file its key includes that the tree
|
|
378
|
+
// lacks (untracked) is not there, and the output it saved under that
|
|
379
|
+
// key was a stale hit for the next run (2026-10-24).
|
|
380
|
+
if (req.inputs !== undefined && req.inputs.files.length > 0) {
|
|
381
|
+
this.inTree ??= listTree(this.o.root, this.o.tree)
|
|
382
|
+
const tree = await this.inTree
|
|
383
|
+
// A dependency's output is restored there before the task runs: a
|
|
384
|
+
// key that reads an upstream's bytes (`inputs.tasks` leaving it out)
|
|
385
|
+
// was refused (2026-10-25).
|
|
386
|
+
const nodes = this.o.nodes()
|
|
387
|
+
const restored = [...upstream(nodes, req.taskId)].flatMap((id) => {
|
|
388
|
+
const n = nodes?.get(id)
|
|
389
|
+
const outs = n?.config.cache?.outputs
|
|
390
|
+
return n === undefined || outs === undefined
|
|
391
|
+
? []
|
|
392
|
+
: [
|
|
393
|
+
names(path.relative(this.o.root, n.projectDir), {
|
|
394
|
+
files: outs.files,
|
|
395
|
+
workspaceFiles: outs.workspaceFiles ?? [],
|
|
396
|
+
}),
|
|
397
|
+
]
|
|
398
|
+
})
|
|
399
|
+
const stray =
|
|
400
|
+
tree === undefined
|
|
401
|
+
? undefined
|
|
402
|
+
: req.inputs.files.find(
|
|
403
|
+
(f) => !tree.files.has(f.path) && !restored.some((made) => made(f.path)),
|
|
404
|
+
)
|
|
405
|
+
if (stray !== undefined) {
|
|
406
|
+
// A committed submodule's files are not the tree's: its gitlink is.
|
|
407
|
+
const link = tree?.links.find((l) => stray.path.startsWith(`${l}/`))
|
|
408
|
+
throw new UserError(
|
|
409
|
+
link === undefined
|
|
410
|
+
? `${req.taskId} keys ${stray.path}, which is not in the commit workers check out: commit it, or leave it out of the task's inputs`
|
|
411
|
+
: `${req.taskId} keys ${stray.path}, in submodule ${link}, whose files workers do not check out: leave it out of the task's inputs`,
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
for (const u of req.inputs?.upstream ?? []) this.upstreamKeys.set(u.taskId, u.hash)
|
|
416
|
+
await this.share(req.taskId)
|
|
417
|
+
return new Promise((resolve, reject) => {
|
|
418
|
+
const need = this.o.need(req.taskId)
|
|
419
|
+
const job: Job = {
|
|
420
|
+
req,
|
|
421
|
+
need: { ...need, platform: req.platform ?? this.o.home },
|
|
422
|
+
queuedAt: performance.now(),
|
|
423
|
+
resolve,
|
|
424
|
+
reject,
|
|
425
|
+
}
|
|
426
|
+
// A signal aborted already fires no `abort`.
|
|
427
|
+
if (req.signal?.aborted === true) return resolve(stopped())
|
|
428
|
+
req.signal?.addEventListener('abort', () => this.abort(job), { once: true })
|
|
429
|
+
this.queue.push(job)
|
|
430
|
+
this.pumpSoon()
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The run's cache facts; once they end, every worker is sent them again before then. */
|
|
435
|
+
private cache(): Promise<CacheFacts> {
|
|
436
|
+
this.facts ??= this.lend(undefined)
|
|
437
|
+
return this.facts
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The run's cache facts, asked now. A remote whose lend failed this time
|
|
442
|
+
* (core lends it nothing) keeps the token `held` lent it until that ends,
|
|
443
|
+
* and is asked again `RELEND_FLOOR_MS` on: sent without it, workers lost
|
|
444
|
+
* the remote while its token held, and for the rest of the run when it
|
|
445
|
+
* was the one remote whose token ends (no relend came again).
|
|
446
|
+
*/
|
|
447
|
+
private lend(held: CacheFacts | undefined): Promise<CacheFacts> {
|
|
448
|
+
let short = false
|
|
449
|
+
const asked = this.o.cacheFacts().then((fresh) => {
|
|
450
|
+
for (const p of Object.keys(fresh.lent)) this.lenders.add(p)
|
|
451
|
+
short = [...this.lenders].some((p) => !(p in fresh.lent))
|
|
452
|
+
const live =
|
|
453
|
+
held !== undefined && (held.expiresAt === undefined || held.expiresAt > Date.now())
|
|
454
|
+
if (!short || !live) return fresh
|
|
455
|
+
const lent = { ...fresh.lent }
|
|
456
|
+
for (const [p, t] of Object.entries(held.lent)) if (!(p in lent)) lent[p] = t
|
|
457
|
+
const expiresAt = Math.min(fresh.expiresAt ?? Infinity, held.expiresAt ?? Infinity)
|
|
458
|
+
return { ...fresh, lent, ...(expiresAt === Infinity ? {} : { expiresAt }) }
|
|
459
|
+
})
|
|
460
|
+
void asked.then(
|
|
461
|
+
(f) => {
|
|
462
|
+
if ((f.expiresAt === undefined && !short) || this.ended) return
|
|
463
|
+
const left = (f.expiresAt ?? Infinity) - Date.now()
|
|
464
|
+
this.relend = setTimeout(
|
|
465
|
+
() => {
|
|
466
|
+
const next = this.lend(f)
|
|
467
|
+
this.facts = next
|
|
468
|
+
void next.then(
|
|
469
|
+
(cache) => {
|
|
470
|
+
if (JSON.stringify(cache) === JSON.stringify(f)) return
|
|
471
|
+
for (const a of this.workers.values()) this.post(a, { type: 'cache', cache })
|
|
472
|
+
},
|
|
473
|
+
// quiet: the workers keep what they hold, and the next ask asks again
|
|
474
|
+
() => {},
|
|
475
|
+
)
|
|
476
|
+
},
|
|
477
|
+
short ? RELEND_FLOOR_MS : left > RELEND_MS ? left - RELEND_MS : RELEND_FLOOR_MS,
|
|
478
|
+
)
|
|
479
|
+
this.relend.unref()
|
|
480
|
+
},
|
|
481
|
+
() => {
|
|
482
|
+
if (this.facts === asked) this.facts = undefined
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
return asked
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async close(): Promise<void> {
|
|
489
|
+
clearTimeout(this.relend)
|
|
490
|
+
this.ended = true
|
|
491
|
+
this.endedAt = performance.now()
|
|
492
|
+
this.stop.abort()
|
|
493
|
+
await this.loop
|
|
494
|
+
await this.o.me.leave()
|
|
495
|
+
for (const a of this.workers.values()) this.trace.left(a.label, 'stopped', this.endedAt)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** The run's trace events (`TaskExecutor.profile`), once it had workers. */
|
|
499
|
+
profile(): Record<string, unknown>[] {
|
|
500
|
+
if (this.spawnedAt.size + this.workers.size + this.gone.length === 0) return []
|
|
501
|
+
return this.trace.events(this.ended ? this.endedAt : performance.now())
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private abort(job: Job): void {
|
|
505
|
+
const at = this.queue.indexOf(job)
|
|
506
|
+
if (at >= 0) {
|
|
507
|
+
this.queue.splice(at, 1)
|
|
508
|
+
job.resolve(stopped())
|
|
509
|
+
} else if (job.worker !== undefined) {
|
|
510
|
+
const a = this.workers.get(job.worker)
|
|
511
|
+
if (a !== undefined) this.post(a, { type: 'cancel', task: job.req.taskId })
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Tasks core starts in one tick go out as one batch. */
|
|
516
|
+
private pumpSoon(): void {
|
|
517
|
+
if (this.pumping) return
|
|
518
|
+
this.pumping = true
|
|
519
|
+
setImmediate(() => {
|
|
520
|
+
this.pumping = false
|
|
521
|
+
this.pump()
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private pump(): void {
|
|
526
|
+
const batches = new Map<Worker, Job[]>()
|
|
527
|
+
const left: Job[] = []
|
|
528
|
+
// Core asks in its critical-path order, but only as tasks get ready: a
|
|
529
|
+
// long chain ready a moment late queued behind 46 shards ready at once,
|
|
530
|
+
// 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
|
+
const workers = [...this.workers.values()].filter((a) => !a.faulty)
|
|
535
|
+
const byId = new Map(waiting.map((j) => [j.req.taskId, j]))
|
|
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)!)
|
|
546
|
+
const bins = workers.map((a) => {
|
|
547
|
+
let running = 0
|
|
548
|
+
for (const j of a.busy.values()) if (j.held !== undefined) running++
|
|
549
|
+
return {
|
|
550
|
+
id: a.id,
|
|
551
|
+
cores: a.cores,
|
|
552
|
+
memory: a.memory,
|
|
553
|
+
used: this.used(a),
|
|
554
|
+
running,
|
|
555
|
+
has: a.has,
|
|
556
|
+
platform: a.platform,
|
|
557
|
+
a,
|
|
558
|
+
}
|
|
559
|
+
})
|
|
560
|
+
const nodes = this.o.nodes()
|
|
561
|
+
const asks = []
|
|
562
|
+
const placed: Job[] = []
|
|
563
|
+
// The least any ask needs: once no worker takes it, the pass ends. Each
|
|
564
|
+
// pump read every queued task, 8000 of them a pump (2026-10-25).
|
|
565
|
+
const floor = { cores: Infinity, memory: Infinity }
|
|
566
|
+
for (const job of queue) {
|
|
567
|
+
const up =
|
|
568
|
+
job.up ??
|
|
569
|
+
(nodes?.has(job.req.taskId) === true
|
|
570
|
+
? (job.up = upstream(nodes, job.req.taskId))
|
|
571
|
+
: upstream(nodes, job.req.taskId))
|
|
572
|
+
const pin = this.pin(job, up)
|
|
573
|
+
if (pin === 'done') continue
|
|
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,
|
|
587
|
+
})
|
|
588
|
+
for (const job of placed) {
|
|
589
|
+
const a = to.get(job.req.taskId)?.a
|
|
590
|
+
if (a === undefined) {
|
|
591
|
+
left.push(job)
|
|
592
|
+
continue
|
|
593
|
+
}
|
|
594
|
+
job.worker = a.id
|
|
595
|
+
job.sentAt = performance.now()
|
|
596
|
+
a.busy.set(job.req.taskId, job)
|
|
597
|
+
job.held = {
|
|
598
|
+
cores: Math.min(this.booking(job.need).cores, a.cores),
|
|
599
|
+
memory: Math.min(job.need.memory, a.memory),
|
|
600
|
+
}
|
|
601
|
+
job.booked = job.held.cores
|
|
602
|
+
this.trace.slice({
|
|
603
|
+
track: ORCHESTRATOR,
|
|
604
|
+
name: job.req.taskId,
|
|
605
|
+
cat: 'queue',
|
|
606
|
+
from: job.queuedAt ?? job.sentAt,
|
|
607
|
+
to: job.sentAt,
|
|
608
|
+
args: { to: a.label },
|
|
609
|
+
})
|
|
610
|
+
a.usedCores += job.held.cores
|
|
611
|
+
a.usedMemory += job.held.memory
|
|
612
|
+
const batch = batches.get(a)
|
|
613
|
+
if (batch === undefined) batches.set(a, [job])
|
|
614
|
+
else batch.push(job)
|
|
615
|
+
}
|
|
616
|
+
this.queue.push(...left)
|
|
617
|
+
for (const [a, jobs] of batches) this.send(a, jobs)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Where `job` may go, as its upstreams' files no cache holds lie (`kept`):
|
|
622
|
+
* on the worker holding them, waiting there up to `PIN_WAIT_MS`. Past
|
|
623
|
+
* that, with that worker gone, or with them on more than one, each
|
|
624
|
+
* upstream not on the worker holding most runs again where the job may
|
|
625
|
+
* go, and the job waits for it. `'wait'`: not yet; `'done'`: it failed.
|
|
626
|
+
*/
|
|
627
|
+
private pin(
|
|
628
|
+
job: Job,
|
|
629
|
+
up: ReadonlySet<string>,
|
|
630
|
+
): 'wait' | 'done' | { only?: string; avoid?: string } {
|
|
631
|
+
if (job.again !== undefined) {
|
|
632
|
+
const { only } = job.again
|
|
633
|
+
const waited = performance.now() - (job.pinnedAt ?? performance.now())
|
|
634
|
+
const there = only === undefined || this.workers.get(only)?.faulty === false
|
|
635
|
+
return there && waited < this.pinWaitMs ? job.again : {}
|
|
636
|
+
}
|
|
637
|
+
// Nothing kept or made again: no upstream can pin it.
|
|
638
|
+
if (this.kept.size === 0 && this.remaking.size === 0 && this.unmade.size === 0) return {}
|
|
639
|
+
const held = new Map<string, string[]>()
|
|
640
|
+
for (const id of up) {
|
|
641
|
+
const why = this.unmade.get(id)
|
|
642
|
+
if (why !== undefined) {
|
|
643
|
+
job.resolve(result({ exitCode: 1, durationMs: 0 }, [{ text: `${why}\n`, err: true }]))
|
|
644
|
+
return 'done'
|
|
645
|
+
}
|
|
646
|
+
if (this.remaking.has(id)) return 'wait'
|
|
647
|
+
const k = this.kept.get(id)
|
|
648
|
+
if (k === undefined) continue
|
|
649
|
+
const on = held.get(k.worker)
|
|
650
|
+
if (on === undefined) held.set(k.worker, [id])
|
|
651
|
+
else on.push(id)
|
|
652
|
+
}
|
|
653
|
+
if (held.size === 0) return {}
|
|
654
|
+
const now = performance.now()
|
|
655
|
+
if (job.pinnedAt === undefined) {
|
|
656
|
+
job.pinnedAt = now
|
|
657
|
+
setTimeout(() => this.pumpSoon(), this.pinWaitMs + 50).unref?.()
|
|
658
|
+
}
|
|
659
|
+
// A worker of another platform cannot take it: they run again on one that can.
|
|
660
|
+
const platform = job.need.platform
|
|
661
|
+
const live = [...held].filter(([w]) => {
|
|
662
|
+
const a = this.workers.get(w)
|
|
663
|
+
return a?.faulty === false && (platform === undefined || a.platform === platform)
|
|
664
|
+
})
|
|
665
|
+
live.sort((x, y) => y[1].length - x[1].length)
|
|
666
|
+
const target = live[0]?.[0]
|
|
667
|
+
if (target !== undefined && held.size === 1) {
|
|
668
|
+
if (job.fellBack === true || now - job.pinnedAt < this.pinWaitMs) return { only: target }
|
|
669
|
+
// Its worker stayed full: they run again on another, and it follows.
|
|
670
|
+
this.remake(held.get(target)!, { avoid: target })
|
|
671
|
+
} else
|
|
672
|
+
for (const [w, ids] of held)
|
|
673
|
+
if (w !== target) this.remake(ids, target === undefined ? { platform } : { only: target })
|
|
674
|
+
job.fellBack = true
|
|
675
|
+
delete job.pinnedAt
|
|
676
|
+
return 'wait'
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private get pinWaitMs(): number {
|
|
680
|
+
return this.o.pinWaitMs ?? PIN_WAIT_MS
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** `ids`, uncached tasks that ran, run again where `where` says, for a dependant. */
|
|
684
|
+
private remake(
|
|
685
|
+
ids: readonly string[],
|
|
686
|
+
where: { only?: string; avoid?: string; platform?: string | undefined },
|
|
687
|
+
): void {
|
|
688
|
+
for (const id of ids) {
|
|
689
|
+
const k = this.kept.get(id)
|
|
690
|
+
if (k === undefined || this.remaking.has(id)) continue
|
|
691
|
+
this.remaking.add(id)
|
|
692
|
+
this.kept.delete(id)
|
|
693
|
+
const done = (why: string | undefined): void => {
|
|
694
|
+
this.remaking.delete(id)
|
|
695
|
+
if (why !== undefined) this.unmade.set(id, why)
|
|
696
|
+
this.pumpSoon()
|
|
697
|
+
}
|
|
698
|
+
this.pumpSoon()
|
|
699
|
+
this.queue.unshift({
|
|
700
|
+
req: k.req,
|
|
701
|
+
need: where.platform === undefined ? k.need : { ...k.need, platform: where.platform },
|
|
702
|
+
queuedAt: performance.now(),
|
|
703
|
+
pinnedAt: performance.now(),
|
|
704
|
+
again: {
|
|
705
|
+
...(where.only === undefined ? {} : { only: where.only }),
|
|
706
|
+
...(where.avoid === undefined ? {} : { avoid: where.avoid }),
|
|
707
|
+
},
|
|
708
|
+
resolve: (r) =>
|
|
709
|
+
done(
|
|
710
|
+
r.exitCode === 0 ? undefined : `${id} ran again for it and failed (exit ${r.exitCode})`,
|
|
711
|
+
),
|
|
712
|
+
reject: (err) =>
|
|
713
|
+
done(
|
|
714
|
+
`${id} ran again for it and failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
715
|
+
),
|
|
716
|
+
})
|
|
717
|
+
if (where.only !== undefined || where.avoid !== undefined)
|
|
718
|
+
setTimeout(() => this.pumpSoon(), this.pinWaitMs + 50).unref?.()
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* What worker `a` has in use: what the run booked there, or more when
|
|
724
|
+
* its last load and what was sent since say more. Never less: load lags
|
|
725
|
+
* a task's start, so placing below the booking sent small#3 38 test
|
|
726
|
+
* shards on 4 cores at once, 28 failed (main bf77a58, 2026-10-23).
|
|
727
|
+
*/
|
|
728
|
+
private used(a: Worker): { cores: number; memory: number } {
|
|
729
|
+
const booked = { cores: a.usedCores, memory: a.usedMemory }
|
|
730
|
+
const l = a.load
|
|
731
|
+
if (l === undefined || a.cores !== a.reported.cores) return booked
|
|
732
|
+
let cores = l.cpu
|
|
733
|
+
let memory = l.memory
|
|
734
|
+
for (const j of a.busy.values())
|
|
735
|
+
if (j.held !== undefined && (j.sentAt ?? 0) > l.at) {
|
|
736
|
+
cores += j.held.cores
|
|
737
|
+
memory += j.held.memory
|
|
738
|
+
}
|
|
739
|
+
return { cores: Math.max(booked.cores, cores), memory: Math.max(booked.memory, memory) }
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Whether a task of `a`'s holds its cores: its command still runs, or has yet to. */
|
|
743
|
+
private holding(a: Worker): boolean {
|
|
744
|
+
for (const j of a.busy.values()) if (j.held !== undefined) return true
|
|
745
|
+
return false
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* What a task books: a measured one its need times the share of their
|
|
750
|
+
* history the run's tasks used, once they used `LEARN_MS` of it, and
|
|
751
|
+
* never under `1 / OVERBOOK` of its need. An unmeasured one (`ask`)
|
|
752
|
+
* books what it books.
|
|
753
|
+
*/
|
|
754
|
+
booking(need: Need): Need {
|
|
755
|
+
if (need.cores === Infinity || need.ask !== undefined || this.needMs < LEARN_MS) return need
|
|
756
|
+
const used = Math.min(1, Math.max(1 / OVERBOOK, this.usedMs / this.needMs))
|
|
757
|
+
return { ...need, cores: need.cores * used }
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* `job`'s cores and memory go back to its worker, once: its command
|
|
762
|
+
* ended, or it left. Its worker's last load counted it busy, and its
|
|
763
|
+
* save: kept, a Mac read 2.9 cores busy with one 1.1-core task left and
|
|
764
|
+
* the next task waited for the next frame (main 2975e38, 2026-10-24).
|
|
765
|
+
*/
|
|
766
|
+
private unhold(a: Worker, job: Job): void {
|
|
767
|
+
delete a.load
|
|
768
|
+
if (job.held === undefined) return
|
|
769
|
+
a.usedCores -= job.held.cores
|
|
770
|
+
a.usedMemory -= job.held.memory
|
|
771
|
+
if (a.started.has(job.req.taskId)) a.usage.book(-job.held.cores)
|
|
772
|
+
delete job.held
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** What a frame of `a`'s says its machine uses now. */
|
|
776
|
+
private heard(a: Worker, load: Load | undefined): void {
|
|
777
|
+
if (load === undefined) return
|
|
778
|
+
a.load = { cpu: Number(load.cpu) || 0, memory: Number(load.memory) || 0, at: performance.now() }
|
|
779
|
+
const avg = Number(load.avg)
|
|
780
|
+
if (Number.isFinite(avg) && avg >= 0) a.usage.machineAvg = avg
|
|
781
|
+
const tasks = Number(load.tasks)
|
|
782
|
+
this.trace.load(a.label, a.load.cpu, a.load.at, Number.isFinite(tasks) ? tasks : undefined)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Whether a worker of `platform` bigger than `above` is starting: a task that filled that size waits for it. */
|
|
786
|
+
private bigger(
|
|
787
|
+
above: { readonly cores: number; readonly memory: number },
|
|
788
|
+
platform: string | undefined,
|
|
789
|
+
): boolean {
|
|
790
|
+
const joined = new Set([...this.workers.values(), ...this.gone].map((x) => x.label))
|
|
791
|
+
for (const [id, { declared }] of this.spawnedAt)
|
|
792
|
+
if (
|
|
793
|
+
!joined.has(id) &&
|
|
794
|
+
!this.unstarted.has(id) &&
|
|
795
|
+
!this.cancelled.has(id) &&
|
|
796
|
+
(platform === undefined || (declared.platform ?? hostPlatform()) === platform) &&
|
|
797
|
+
(declared.cpu > above.cores || declared.memory > above.memory)
|
|
798
|
+
)
|
|
799
|
+
return true
|
|
800
|
+
return false
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* This send's number for `task`. A remote keeps the first bytes put
|
|
805
|
+
* under a key, as this machine's store keeps the ones it restored: a
|
|
806
|
+
* retry saved under its first attempt's run key restored that failure's
|
|
807
|
+
* files and output under a green result (2026-10-24).
|
|
808
|
+
*/
|
|
809
|
+
private sends(task: string): number {
|
|
810
|
+
const n = this.sent.get(task) ?? 0
|
|
811
|
+
this.sent.set(task, n + 1)
|
|
812
|
+
return n
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Each dependency of `id` this machine restored, a hit no worker ran, put
|
|
817
|
+
* on a remote the workers read: a worker reads the remotes alone, and a
|
|
818
|
+
* hit only this cache held (the remote evicted it, or this machine does
|
|
819
|
+
* not write it) failed the dependant there (2026-10-24).
|
|
820
|
+
*/
|
|
821
|
+
private async share(id: string): Promise<void> {
|
|
822
|
+
const nodes = this.o.nodes()
|
|
823
|
+
const keys: string[] = []
|
|
824
|
+
for (const d of upstream(nodes, id)) {
|
|
825
|
+
if (this.savedAs.has(d) || nodes?.get(d)?.config.cache?.outputs === undefined) continue
|
|
826
|
+
const key = this.upstreamKeys.get(d) ?? this.o.keyOf(d)
|
|
827
|
+
if (key !== undefined) keys.push(key)
|
|
828
|
+
}
|
|
829
|
+
await Promise.all(
|
|
830
|
+
keys.map((k) => {
|
|
831
|
+
let got = this.shared.get(k)
|
|
832
|
+
if (got === undefined) {
|
|
833
|
+
// quiet: a key no remote took fails on the worker, by name
|
|
834
|
+
got = this.o.artifacts.share(k).catch(() => false)
|
|
835
|
+
this.shared.set(k, got)
|
|
836
|
+
}
|
|
837
|
+
return got
|
|
838
|
+
}),
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* True when one of `keys` `job` did not run again for already reached a
|
|
844
|
+
* remote. Each key is sent once and each job runs again once for it:
|
|
845
|
+
* two dependants failing on one key together, the second found it sent
|
|
846
|
+
* and failed, though the copy it could not read was replaced.
|
|
847
|
+
*/
|
|
848
|
+
private async resend(job: Job, keys: readonly string[]): Promise<boolean> {
|
|
849
|
+
const fresh = keys.filter((k) => typeof k === 'string' && job.reread?.has(k) !== true)
|
|
850
|
+
if (fresh.length === 0) return false
|
|
851
|
+
job.reread = new Set([...(job.reread ?? []), ...fresh])
|
|
852
|
+
const got = await Promise.all(
|
|
853
|
+
fresh.map((k) => {
|
|
854
|
+
let sent = this.resent.get(k)
|
|
855
|
+
if (sent === undefined) {
|
|
856
|
+
// quiet: a key no remote took fails the task, by name
|
|
857
|
+
sent = this.o.artifacts.resend(k).catch(() => false)
|
|
858
|
+
this.resent.set(k, sent)
|
|
859
|
+
}
|
|
860
|
+
return sent
|
|
861
|
+
}),
|
|
862
|
+
)
|
|
863
|
+
return got.includes(true)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
private send(a: Worker, jobs: readonly Job[]): void {
|
|
867
|
+
const nodes = this.o.nodes()
|
|
868
|
+
const orders = jobs.map((job) => {
|
|
869
|
+
const { req } = job
|
|
870
|
+
const deps: DepOrder[] = []
|
|
871
|
+
// Every one, held there or not: the worker restores each key once, and
|
|
872
|
+
// one it was sent but gave back it never ran or restored.
|
|
873
|
+
for (const id of upstream(nodes, req.taskId)) {
|
|
874
|
+
const n = nodes?.get(id)
|
|
875
|
+
const outs = n?.config.cache?.outputs
|
|
876
|
+
const key = this.savedAs.get(id) ?? this.upstreamKeys.get(id) ?? this.o.keyOf(id)
|
|
877
|
+
if (n === undefined || outs === undefined || key === undefined) continue
|
|
878
|
+
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
|
+
})
|
|
885
|
+
}
|
|
886
|
+
// A task sent here leaves its outputs here for its dependants.
|
|
887
|
+
a.has.add(req.taskId)
|
|
888
|
+
const node = nodes?.get(req.taskId)
|
|
889
|
+
const env = defined(req.env, req.envDefine)
|
|
890
|
+
return {
|
|
891
|
+
job,
|
|
892
|
+
order: {
|
|
893
|
+
task: req.taskId,
|
|
894
|
+
// Unsaved: under the run's own key, as an uncached task's.
|
|
895
|
+
...(req.cacheKey === undefined || req.unsaved === true ? {} : { key: req.cacheKey }),
|
|
896
|
+
runKey: runKey(this.o.run, req.taskId, this.sends(req.taskId)),
|
|
897
|
+
// A held key keeps its entry, and restored here it replaced the
|
|
898
|
+
// forced run's fresh bytes with the ones it bypassed (2026-10-25).
|
|
899
|
+
...(req.refresh === true ? { fresh: true as const } : {}),
|
|
900
|
+
// A HEAD per upload was the workers' only remote probe: the run
|
|
901
|
+
// already knows the key is in no cache (2026-10-25).
|
|
902
|
+
...(req.refresh !== true && this.missed.has(req.taskId) ? { absent: true as const } : {}),
|
|
903
|
+
deps,
|
|
904
|
+
nested: node === undefined ? [] : nestedIn(nodes!, node.projectDir, this.o.root),
|
|
905
|
+
command: withForwardArgs(req.command, req.forwardArgs),
|
|
906
|
+
cwd: path.relative(this.o.root, req.cwd),
|
|
907
|
+
project: node === undefined ? '' : path.relative(this.o.root, node.projectDir),
|
|
908
|
+
env,
|
|
909
|
+
...(node?.config.exec?.env?.secret === undefined
|
|
910
|
+
? {}
|
|
911
|
+
: { secret: [...node.config.exec.env.secret] }),
|
|
912
|
+
...(req.timeoutMs === undefined ? {} : { timeoutMs: req.timeoutMs }),
|
|
913
|
+
outputs: {
|
|
914
|
+
files: [...req.outputs.files],
|
|
915
|
+
workspaceFiles: [...req.outputs.workspaceFiles],
|
|
916
|
+
},
|
|
917
|
+
cores: job.held?.cores ?? 1,
|
|
918
|
+
memory: job.held?.memory ?? 0,
|
|
919
|
+
...(req.sandbox === undefined || node?.config.exec?.sandbox === undefined
|
|
920
|
+
? {}
|
|
921
|
+
: {
|
|
922
|
+
sandbox: {
|
|
923
|
+
config: node.config.exec.sandbox,
|
|
924
|
+
...(req.sandbox.keyed === undefined
|
|
925
|
+
? {}
|
|
926
|
+
: { keyed: req.sandbox.keyed.map((d) => path.relative(this.o.root, d)) }),
|
|
927
|
+
},
|
|
928
|
+
}),
|
|
929
|
+
...runtimeOrders(req),
|
|
930
|
+
...(req.servers === undefined || req.servers.length === 0
|
|
931
|
+
? {}
|
|
932
|
+
: {
|
|
933
|
+
servers: req.servers.map((s) => ({
|
|
934
|
+
task: s.taskId,
|
|
935
|
+
command: s.command,
|
|
936
|
+
cwd: path.relative(this.o.root, s.cwd),
|
|
937
|
+
env: defined(s.env, {}),
|
|
938
|
+
...(s.readyWhen === undefined ? {} : { readyWhen: s.readyWhen }),
|
|
939
|
+
...(s.timeoutMs === undefined ? {} : { timeoutMs: s.timeoutMs }),
|
|
940
|
+
...(s.sandbox === undefined
|
|
941
|
+
? {}
|
|
942
|
+
: {
|
|
943
|
+
sandbox: {
|
|
944
|
+
config: s.sandbox.config,
|
|
945
|
+
nested: s.sandbox.nested.map((d) => path.relative(this.o.root, d)),
|
|
946
|
+
},
|
|
947
|
+
}),
|
|
948
|
+
})),
|
|
949
|
+
}),
|
|
950
|
+
} as RunOrder,
|
|
951
|
+
}
|
|
952
|
+
})
|
|
953
|
+
const sandboxed = orders.some(
|
|
954
|
+
(o) => o.order.sandbox !== undefined || o.order.servers?.some((s) => s.sandbox !== undefined),
|
|
955
|
+
)
|
|
956
|
+
const sandboxes = sandboxed && !a.sandboxes ? sandboxConfigs(nodes) : undefined
|
|
957
|
+
if (sandboxed) a.sandboxes = true
|
|
958
|
+
const pending = new Set(jobs)
|
|
959
|
+
a.chain = a.chain
|
|
960
|
+
.then(async () => {
|
|
961
|
+
for (const [i, part] of parts(orders, BATCH_BYTES).entries()) {
|
|
962
|
+
// An order too long for a frame (a task of thousands of
|
|
963
|
+
// dependencies was refused, 413) sends its deps ahead.
|
|
964
|
+
const tasks: RunOrder[] = []
|
|
965
|
+
for (const { order } of part) {
|
|
966
|
+
if (bytesOf(order) <= BATCH_BYTES) {
|
|
967
|
+
tasks.push(order)
|
|
968
|
+
continue
|
|
969
|
+
}
|
|
970
|
+
for (const deps of chunks(order.deps, BATCH_BYTES)) {
|
|
971
|
+
const ahead = { type: 'deps', task: order.task, deps } satisfies ToWorker
|
|
972
|
+
await this.o.me.send(a.id, ahead)
|
|
973
|
+
a.usage.messagesIn++
|
|
974
|
+
a.usage.bytesIn += bytesOf(ahead)
|
|
975
|
+
}
|
|
976
|
+
tasks.push({ ...order, deps: [] })
|
|
977
|
+
}
|
|
978
|
+
const batch = {
|
|
979
|
+
type: 'batch',
|
|
980
|
+
root: this.o.root,
|
|
981
|
+
realRoot: this.realRoot,
|
|
982
|
+
tasks,
|
|
983
|
+
...(sandboxes === undefined || i > 0 ? {} : { sandboxes }),
|
|
984
|
+
} satisfies ToWorker
|
|
985
|
+
await this.o.me.send(a.id, batch)
|
|
986
|
+
a.usage.messagesIn++
|
|
987
|
+
a.usage.bytesIn += bytesOf(batch)
|
|
988
|
+
for (const { job } of part) {
|
|
989
|
+
this.lease(job)
|
|
990
|
+
pending.delete(job)
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
})
|
|
994
|
+
.catch((err: unknown) => {
|
|
995
|
+
// The relay refused the send (the worker is gone): the loss notice
|
|
996
|
+
// puts the tasks back.
|
|
997
|
+
if (err instanceof RelayError && err.status === 404) return
|
|
998
|
+
// One its worker never got leaves its booking there: held with no
|
|
999
|
+
// lease to end it, the worker's cores were gone for the run. One
|
|
1000
|
+
// sent before runs there, and ends as any; one its worker's loss
|
|
1001
|
+
// put back is the queue's.
|
|
1002
|
+
for (const job of pending)
|
|
1003
|
+
if (a.busy.get(job.req.taskId) === job && this.retry(a, job, 'not sent') !== undefined)
|
|
1004
|
+
job.reject(err)
|
|
1005
|
+
this.pumpSoon()
|
|
1006
|
+
})
|
|
1007
|
+
}
|
|
1008
|
+
|
|
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
|
+
/**
|
|
1015
|
+
* Worker `a` let `job` down: lost, its lease ended, or it could not run
|
|
1016
|
+
* it. One it had begun counts, and two workers fail it; one still in its
|
|
1017
|
+
* queue goes back free.
|
|
1018
|
+
*/
|
|
1019
|
+
private retry(a: Worker, job: Job, why: string): Job | undefined {
|
|
1020
|
+
const now = performance.now()
|
|
1021
|
+
this.trace.slice({
|
|
1022
|
+
track: a.label,
|
|
1023
|
+
name: job.req.taskId,
|
|
1024
|
+
cat: 'task',
|
|
1025
|
+
from: job.sentAt ?? now,
|
|
1026
|
+
to: now,
|
|
1027
|
+
args: { failed: why },
|
|
1028
|
+
})
|
|
1029
|
+
job.queuedAt = now
|
|
1030
|
+
a.busy.delete(job.req.taskId)
|
|
1031
|
+
// Its outputs are not there to place a dependant by.
|
|
1032
|
+
a.has.delete(job.req.taskId)
|
|
1033
|
+
this.unhold(a, job)
|
|
1034
|
+
delete job.worker
|
|
1035
|
+
delete job.leaseUntil
|
|
1036
|
+
delete job.sentAt
|
|
1037
|
+
delete job.ranAt
|
|
1038
|
+
const begun = a.started.delete(job.req.taskId)
|
|
1039
|
+
// Stopped, it runs nowhere again; the cancel sent its worker has no one to answer it.
|
|
1040
|
+
if (job.req.signal?.aborted === true) {
|
|
1041
|
+
job.resolve(stopped())
|
|
1042
|
+
return undefined
|
|
1043
|
+
}
|
|
1044
|
+
if (begun) {
|
|
1045
|
+
a.faulty = true
|
|
1046
|
+
job.faults = [...(job.faults ?? []), `${a.label} ${why}`]
|
|
1047
|
+
if (job.faults.length >= 2) {
|
|
1048
|
+
job.reject(new Error(`${job.req.taskId}: failed on 2 workers: ${job.faults.join(', ')}`))
|
|
1049
|
+
return undefined
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return job
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Nothing began or ended for `STALL_MS`: each task a worker holds, and
|
|
1057
|
+
* whether it runs or saves. Main 32f73ed sat 13 min saying nothing
|
|
1058
|
+
* (2026-10-23).
|
|
1059
|
+
*/
|
|
1060
|
+
private stall(): void {
|
|
1061
|
+
const now = performance.now()
|
|
1062
|
+
if (now - this.movedAt < (this.o.stallMs ?? STALL_MS)) return
|
|
1063
|
+
const lines: string[] = []
|
|
1064
|
+
for (const a of this.workers.values())
|
|
1065
|
+
for (const j of a.busy.values()) {
|
|
1066
|
+
const s = (t: number | undefined) => `${Math.round((now - (t ?? now)) / 1000)} s`
|
|
1067
|
+
lines.push(
|
|
1068
|
+
j.ranAt !== undefined
|
|
1069
|
+
? `${j.req.taskId} on ${a.label}: saving its outputs for ${s(j.ranAt)}`
|
|
1070
|
+
: a.started.has(j.req.taskId)
|
|
1071
|
+
? `${j.req.taskId} on ${a.label}: running for ${s(j.startedAt)}`
|
|
1072
|
+
: `${j.req.taskId} sent to ${a.label} ${s(j.sentAt)} ago, not begun`,
|
|
1073
|
+
)
|
|
1074
|
+
}
|
|
1075
|
+
if (lines.length === 0) return
|
|
1076
|
+
this.movedAt = now
|
|
1077
|
+
this.trace.mark(ORCHESTRATOR, 'stalled', { waiting: lines })
|
|
1078
|
+
this.o.stalled?.(lines.join('\n'))
|
|
1079
|
+
}
|
|
1080
|
+
|
|
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
|
+
private post(a: Worker, m: ToWorker): void {
|
|
1101
|
+
a.chain = a.chain.then(() => this.o.me.send(a.id, m)).catch(this.unsent(m.type, a.label))
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
private unsent(what: string, to: string): (err: unknown) => void {
|
|
1105
|
+
return unsent(this.warn, what, to)
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
private readonly warn = (m: string): void =>
|
|
1109
|
+
(this.o.warn ?? ((l) => ui.say('warn', l)))(`[vx] ${m}`)
|
|
1110
|
+
|
|
1111
|
+
/** Worker `a` began `task`: from now its cores are booked. */
|
|
1112
|
+
private begun(a: Worker, task: string): void {
|
|
1113
|
+
const job = a.busy.get(task)
|
|
1114
|
+
if (job === undefined || a.started.has(task)) return
|
|
1115
|
+
a.started.add(task)
|
|
1116
|
+
job.startedAt = performance.now()
|
|
1117
|
+
a.usage.book(job.held?.cores ?? 0)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** Worker `a` ran `task` and saved it under `key`: it is done there, and done here once restored. */
|
|
1121
|
+
private async finish(a: Worker, task: string, key: string, head: WiredResult): Promise<void> {
|
|
1122
|
+
const job = a.busy.get(task)
|
|
1123
|
+
if (job === undefined) return
|
|
1124
|
+
// A dependency's remote copy did not read back there (a truncated
|
|
1125
|
+
// upload): sent again from this machine, the task runs again. Taken as
|
|
1126
|
+
// the task's failure, it failed every run, the remote saying it held the
|
|
1127
|
+
// 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
|
+
if (head.unread !== undefined) {
|
|
1132
|
+
const sent = await this.resend(job, head.unread)
|
|
1133
|
+
// Its worker was lost meanwhile: the task went back to the queue.
|
|
1134
|
+
if (a.busy.get(task) !== job) return
|
|
1135
|
+
if (sent) {
|
|
1136
|
+
// Its cores back while it still counts as begun, which `unhold` reads;
|
|
1137
|
+
// then not begun, so `retry` holds no fault against its worker.
|
|
1138
|
+
this.unhold(a, job)
|
|
1139
|
+
a.started.delete(task)
|
|
1140
|
+
const again = this.retry(a, job, `could not read ${head.unread.join(', ')}`)
|
|
1141
|
+
if (again !== undefined) {
|
|
1142
|
+
this.queue.unshift(again)
|
|
1143
|
+
this.pumpSoon()
|
|
1144
|
+
}
|
|
1145
|
+
return
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
const endAt = performance.now()
|
|
1149
|
+
this.traceTask(a, job, head, endAt)
|
|
1150
|
+
a.busy.delete(task)
|
|
1151
|
+
this.unhold(a, job)
|
|
1152
|
+
a.started.delete(task)
|
|
1153
|
+
a.ran++
|
|
1154
|
+
a.done.push(task)
|
|
1155
|
+
a.usage.record(head, job.sentAt)
|
|
1156
|
+
const share = shareOf(head.cpuShare)
|
|
1157
|
+
const held = Math.max(1, job.startedAt === undefined ? head.durationMs : endAt - job.startedAt)
|
|
1158
|
+
this.took.set(task, {
|
|
1159
|
+
ms: held,
|
|
1160
|
+
cores:
|
|
1161
|
+
head.cpuMs === undefined
|
|
1162
|
+
? (job.held?.cores ?? 1)
|
|
1163
|
+
: // The cores it used over the time it held: its free cores over
|
|
1164
|
+
// its crowded length counted the crowd twice (6a7224f: ideal
|
|
1165
|
+
// 127.8 s, real 111.0 s).
|
|
1166
|
+
Math.min(a.cores, head.cpuMs / held),
|
|
1167
|
+
memory: head.peakRssBytes ?? job.held?.memory ?? 0,
|
|
1168
|
+
platform: a.platform,
|
|
1169
|
+
})
|
|
1170
|
+
if (
|
|
1171
|
+
job.need.cores !== Infinity &&
|
|
1172
|
+
job.need.ask === undefined &&
|
|
1173
|
+
head.cpuMs !== undefined &&
|
|
1174
|
+
head.exitCode === 0
|
|
1175
|
+
) {
|
|
1176
|
+
const ms = Math.max(1, head.durationMs)
|
|
1177
|
+
// What it would have used with a core free: on a crowded worker its
|
|
1178
|
+
// share only, and booking the next tasks by that packed them tighter.
|
|
1179
|
+
this.usedMs += Math.min(head.cpuMs / share, a.cores * ms)
|
|
1180
|
+
this.needMs += Math.min(job.need.cores, a.cores) * ms
|
|
1181
|
+
}
|
|
1182
|
+
this.savedAs.set(task, key)
|
|
1183
|
+
const node = this.o.nodes()?.get(task)
|
|
1184
|
+
// No remote took its upload (too big, a 429): its files are there
|
|
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))))
|
|
1188
|
+
this.kept.set(task, { worker: a.id, req: job.req, need: job.need })
|
|
1189
|
+
this.pumpSoon()
|
|
1190
|
+
if (job.again !== undefined) return job.resolve(result(head, []))
|
|
1191
|
+
const restore = () =>
|
|
1192
|
+
this.o.artifacts.restore(key, {
|
|
1193
|
+
id: task,
|
|
1194
|
+
projectDir: node?.projectDir ?? job.req.cwd,
|
|
1195
|
+
command: job.req.command,
|
|
1196
|
+
outputs: job.req.outputs,
|
|
1197
|
+
})
|
|
1198
|
+
// A remote-only task's files stay off this disk: an install's
|
|
1199
|
+
// node_modules laid over this machine's own (schema: `'only'`). A
|
|
1200
|
+
// deferred one's land only if a task here needs them: under
|
|
1201
|
+
// `--cache-download=none` every file of the run came home (2026-10-24).
|
|
1202
|
+
const deferred = job.req.remoteOnly !== true && job.req.download === 'deferred'
|
|
1203
|
+
const read = () =>
|
|
1204
|
+
job.req.remoteOnly === true || deferred ? this.o.artifacts.output(key) : restore()
|
|
1205
|
+
const log = await (
|
|
1206
|
+
unshared
|
|
1207
|
+
? Promise.resolve(wiredLog(head.output))
|
|
1208
|
+
: // A failed download reads as a miss: asked once more, since one
|
|
1209
|
+
// stalled download failed a CI run whose upload had landed
|
|
1210
|
+
// (main, 2026-10-28).
|
|
1211
|
+
read().then((got) => got ?? read())
|
|
1212
|
+
).catch((err: unknown) => {
|
|
1213
|
+
job.reject(err)
|
|
1214
|
+
return undefined
|
|
1215
|
+
})
|
|
1216
|
+
this.trace.slice({
|
|
1217
|
+
track: ORCHESTRATOR,
|
|
1218
|
+
name: task,
|
|
1219
|
+
cat: 'restore',
|
|
1220
|
+
from: endAt,
|
|
1221
|
+
to: performance.now(),
|
|
1222
|
+
})
|
|
1223
|
+
if (log === undefined) return
|
|
1224
|
+
if (log === null) {
|
|
1225
|
+
job.reject(
|
|
1226
|
+
new Error(
|
|
1227
|
+
`${task}: ${a.label} ran it, and no cache this machine reads gave its entry, asked twice: a download failed (warned above), ${a.label}'s upload failed (its log says why), or the workers and this machine share no remote cache the workers write`,
|
|
1228
|
+
),
|
|
1229
|
+
)
|
|
1230
|
+
return
|
|
1231
|
+
}
|
|
1232
|
+
// An upstream's file the task removed there is gone here too: a restore
|
|
1233
|
+
// lays files, never removes one, and this disk is what core saves from.
|
|
1234
|
+
if (head.removed !== undefined && job.req.remoteOnly !== true && !deferred && !unshared) {
|
|
1235
|
+
const ours = names(
|
|
1236
|
+
path.relative(this.o.root, node?.projectDir ?? job.req.cwd),
|
|
1237
|
+
job.req.outputs,
|
|
1238
|
+
)
|
|
1239
|
+
// A link on the path (`dist/in -> ../../other`) would lead the rm
|
|
1240
|
+
// into another project or off the workspace: the path is removed only
|
|
1241
|
+
// where it is, as a restore lays a file only there (archive.ts).
|
|
1242
|
+
const real = new Map<string, Promise<string | undefined>>()
|
|
1243
|
+
for (const f of head.removed) {
|
|
1244
|
+
if (typeof f !== 'string' || f.split('/').includes('..') || !ours(f)) continue
|
|
1245
|
+
const dir = path.dirname(f)
|
|
1246
|
+
let at = real.get(dir)
|
|
1247
|
+
if (at === undefined)
|
|
1248
|
+
real.set(
|
|
1249
|
+
dir,
|
|
1250
|
+
(at = realpath(path.join(this.o.root, dir)).catch(
|
|
1251
|
+
// quiet: a dir that is gone holds nothing to remove
|
|
1252
|
+
() => undefined,
|
|
1253
|
+
)),
|
|
1254
|
+
)
|
|
1255
|
+
const where = await at
|
|
1256
|
+
if (where === undefined) continue
|
|
1257
|
+
if (where === path.join(this.realRoot, dir))
|
|
1258
|
+
await rm(path.join(this.o.root, f), { force: true })
|
|
1259
|
+
else
|
|
1260
|
+
this.warn(
|
|
1261
|
+
`dte: ${task}: kept ${f}, which the worker removed: a link on its path leads elsewhere`,
|
|
1262
|
+
)
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
// In the order the task wrote it: replayed a stream at a time, an error
|
|
1266
|
+
// line came after the stdout that followed it (2026-10-25).
|
|
1267
|
+
for (const c of log) (c.err ? job.req.onStderr : job.req.onStdout)(c.text)
|
|
1268
|
+
job.resolve({
|
|
1269
|
+
...result(head, log),
|
|
1270
|
+
...(unshared
|
|
1271
|
+
? {
|
|
1272
|
+
outputs: {
|
|
1273
|
+
kind: 'deferred' as const,
|
|
1274
|
+
materialize: () =>
|
|
1275
|
+
Promise.reject(
|
|
1276
|
+
new UserError(
|
|
1277
|
+
`${task}: its files are on ${a.label} alone: no remote cache took its upload (its log says why)`,
|
|
1278
|
+
),
|
|
1279
|
+
),
|
|
1280
|
+
},
|
|
1281
|
+
}
|
|
1282
|
+
: deferred
|
|
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
|
+
: {}),
|
|
1293
|
+
where: a.label,
|
|
1294
|
+
machine: {
|
|
1295
|
+
cores: a.reported.cores,
|
|
1296
|
+
memoryBytes: a.reported.memory,
|
|
1297
|
+
...(share < 1 ? { cpuShare: share } : {}),
|
|
1298
|
+
},
|
|
1299
|
+
})
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* Task `job` ended on `a` at `endAt`: one slice from its send, in its
|
|
1304
|
+
* parts (sent, restoring its dependencies, its command, saving), and
|
|
1305
|
+
* the cores it booked there and used.
|
|
1306
|
+
*/
|
|
1307
|
+
private traceTask(a: Worker, job: Job, head: WiredResult, endAt: number): void {
|
|
1308
|
+
const sent = job.sentAt ?? endAt
|
|
1309
|
+
const began = Math.max(sent, job.startedAt ?? sent)
|
|
1310
|
+
const ran = Math.min(endAt, Math.max(began, job.ranAt ?? endAt))
|
|
1311
|
+
const command = Math.max(began, ran - Math.max(0, head.durationMs))
|
|
1312
|
+
this.trace.slice({
|
|
1313
|
+
track: a.label,
|
|
1314
|
+
name: job.req.taskId,
|
|
1315
|
+
cat: 'task',
|
|
1316
|
+
from: sent,
|
|
1317
|
+
to: endAt,
|
|
1318
|
+
args: {
|
|
1319
|
+
exitCode: head.exitCode,
|
|
1320
|
+
durationMs: Math.round(head.durationMs),
|
|
1321
|
+
...(head.cpuMs === undefined ? {} : { cpuMs: Math.round(head.cpuMs) }),
|
|
1322
|
+
...(head.peakRssBytes === undefined ? {} : { peakRssBytes: head.peakRssBytes }),
|
|
1323
|
+
bookedCores: job.booked ?? 0,
|
|
1324
|
+
...(head.cpuShare === undefined ? {} : { cpuShare: head.cpuShare }),
|
|
1325
|
+
},
|
|
1326
|
+
parts: [
|
|
1327
|
+
{ name: 'sent', from: sent, to: began },
|
|
1328
|
+
{ name: 'restore deps', from: began, to: command },
|
|
1329
|
+
{ name: 'command', from: command, to: ran },
|
|
1330
|
+
{ name: 'save', from: ran, to: endAt },
|
|
1331
|
+
],
|
|
1332
|
+
})
|
|
1333
|
+
this.trace.use({ track: a.label, from: began, to: ran, booked: job.booked ?? 0, used: 0 })
|
|
1334
|
+
if (head.cpuMs !== undefined && ran > command)
|
|
1335
|
+
this.trace.use({
|
|
1336
|
+
track: a.label,
|
|
1337
|
+
from: command,
|
|
1338
|
+
to: ran,
|
|
1339
|
+
booked: 0,
|
|
1340
|
+
used: head.cpuMs / Math.max(1, head.durationMs),
|
|
1341
|
+
})
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private onRelay(body: { type?: string; id?: string; info?: unknown }): void {
|
|
1345
|
+
if (body.type === 'joined') {
|
|
1346
|
+
const info = body.info as Partial<WorkerInfo> | null
|
|
1347
|
+
const id = body.id!
|
|
1348
|
+
const refuse = (reason: string): void => {
|
|
1349
|
+
void this.o.me
|
|
1350
|
+
.send(id, { type: 'refused', reason } satisfies ToWorker)
|
|
1351
|
+
.catch(this.unsent('refused', id))
|
|
1352
|
+
}
|
|
1353
|
+
if (
|
|
1354
|
+
info?.v !== 1 ||
|
|
1355
|
+
!(info.id === undefined || typeof info.id === 'string') ||
|
|
1356
|
+
!(Number(info.cores) >= 1) ||
|
|
1357
|
+
!(Number(info.memory) >= 0)
|
|
1358
|
+
) {
|
|
1359
|
+
return refuse('speaks another vx-dte protocol')
|
|
1360
|
+
}
|
|
1361
|
+
if (info.tree !== this.o.tree) {
|
|
1362
|
+
return refuse(`checked out tree ${info.tree}, the run is on ${this.o.tree}`)
|
|
1363
|
+
}
|
|
1364
|
+
const platform = typeof info.platform === 'string' ? info.platform : undefined
|
|
1365
|
+
const sized = info.id === undefined ? undefined : this.spawnedAt.get(info.id)?.declared
|
|
1366
|
+
if (sized?.platform !== undefined && platform !== sized.platform) {
|
|
1367
|
+
return refuse(
|
|
1368
|
+
`runs on ${platform ?? 'no platform it names'}, and its size ${sized.size} says ${sized.platform}`,
|
|
1369
|
+
)
|
|
1370
|
+
}
|
|
1371
|
+
// One given up only for joining late is work the run can use: main
|
|
1372
|
+
// db1702f told three smalls GitHub queued 60 s to exit and ended at
|
|
1373
|
+
// 232 s against an ideal of 141 s. The plan lets it go if not.
|
|
1374
|
+
if (info.id !== undefined && this.late.delete(info.id)) {
|
|
1375
|
+
this.cancelled.delete(info.id)
|
|
1376
|
+
this.unstarted.delete(info.id)
|
|
1377
|
+
}
|
|
1378
|
+
if (info.id !== undefined && this.cancelled.has(info.id)) {
|
|
1379
|
+
this.turnedAway.add(info.id)
|
|
1380
|
+
void this.o.me
|
|
1381
|
+
.send(id, { type: 'exit' } satisfies ToWorker)
|
|
1382
|
+
.catch(this.unsent('exit', info.id))
|
|
1383
|
+
return
|
|
1384
|
+
}
|
|
1385
|
+
// A worker this run started keeps the name it was started under;
|
|
1386
|
+
// one that joined on its own is numbered in the order it joined.
|
|
1387
|
+
const spawn = info.id === undefined ? undefined : this.spawnedAt.get(info.id)
|
|
1388
|
+
const asked = spawn?.at
|
|
1389
|
+
const taken = new Set([...this.workers.values(), ...this.gone].map((a) => a.label))
|
|
1390
|
+
let label = asked === undefined ? undefined : info.id
|
|
1391
|
+
while (label === undefined || taken.has(label)) label = `worker#${++this.joined}`
|
|
1392
|
+
// Workers of one host (local()) each report the host: the size it
|
|
1393
|
+
// was started as is its share (Growth, 2026-10-21: 16 wide on 4 cores).
|
|
1394
|
+
const cores = Math.min(Number(info.cores), spawn?.declared.cpu ?? Infinity)
|
|
1395
|
+
const memory = Math.min(Number(info.memory), spawn?.declared.memory ?? Infinity)
|
|
1396
|
+
const warmUpMs = asked === undefined ? undefined : performance.now() - asked
|
|
1397
|
+
this.trace.joined(
|
|
1398
|
+
label,
|
|
1399
|
+
performance.now(),
|
|
1400
|
+
asked,
|
|
1401
|
+
typeof info.booted === 'number' ? info.booted : undefined,
|
|
1402
|
+
)
|
|
1403
|
+
const size = spawn?.declared.size
|
|
1404
|
+
if (warmUpMs !== undefined && size !== undefined && !this.joinLimitMs.has(size)) {
|
|
1405
|
+
this.joinLimitMs.set(size, 2 * warmUpMs)
|
|
1406
|
+
for (const [s, { at, declared }] of this.spawnedAt)
|
|
1407
|
+
if (s !== info.id && declared.size === size) this.deadline(s, at, size)
|
|
1408
|
+
}
|
|
1409
|
+
this.workers.set(id, {
|
|
1410
|
+
id,
|
|
1411
|
+
warmUpMs,
|
|
1412
|
+
declared: spawn?.declared,
|
|
1413
|
+
ran: 0,
|
|
1414
|
+
done: [],
|
|
1415
|
+
usage: new Usage(),
|
|
1416
|
+
cores,
|
|
1417
|
+
memory,
|
|
1418
|
+
reported: { cores: Number(info.cores), memory: Number(info.memory) },
|
|
1419
|
+
platform,
|
|
1420
|
+
usedCores: 0,
|
|
1421
|
+
usedMemory: 0,
|
|
1422
|
+
label,
|
|
1423
|
+
busy: new Map(),
|
|
1424
|
+
started: new Set(),
|
|
1425
|
+
has: new Set(),
|
|
1426
|
+
// Its name first, before any batch.
|
|
1427
|
+
chain: this.cache()
|
|
1428
|
+
.then((cache) =>
|
|
1429
|
+
this.o.me.send(id, {
|
|
1430
|
+
type: 'welcome',
|
|
1431
|
+
name: label,
|
|
1432
|
+
cache,
|
|
1433
|
+
} satisfies ToWorker),
|
|
1434
|
+
)
|
|
1435
|
+
.catch(this.unsent('welcome', label)),
|
|
1436
|
+
sandboxes: false,
|
|
1437
|
+
faulty: false,
|
|
1438
|
+
})
|
|
1439
|
+
this.o.joined?.()
|
|
1440
|
+
} else if (body.type === 'lost') {
|
|
1441
|
+
const a = this.workers.get(body.id!)
|
|
1442
|
+
if (a === undefined) return
|
|
1443
|
+
this.lose(a)
|
|
1444
|
+
} else {
|
|
1445
|
+
return
|
|
1446
|
+
}
|
|
1447
|
+
this.pumpSoon()
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** Worker `a` is gone without a word: its tasks run again elsewhere. */
|
|
1451
|
+
private lose(a: Worker): void {
|
|
1452
|
+
this.workers.delete(a.id)
|
|
1453
|
+
a.usage.to = performance.now()
|
|
1454
|
+
this.trace.left(a.label, 'lost', a.usage.to)
|
|
1455
|
+
this.gone.push(a)
|
|
1456
|
+
// Its tasks run again: first, as they were first to go.
|
|
1457
|
+
const back: Job[] = []
|
|
1458
|
+
for (const j of [...a.busy.values()]) {
|
|
1459
|
+
const again = this.retry(a, j, 'lost')
|
|
1460
|
+
if (again !== undefined) back.push(again)
|
|
1461
|
+
}
|
|
1462
|
+
this.queue.unshift(...back)
|
|
1463
|
+
this.o.lost?.(a.label)
|
|
1464
|
+
// Workers that join on their own may yet join; ones this run starts
|
|
1465
|
+
// are all it gets.
|
|
1466
|
+
if (this.spawnedAt.size > 0) this.stranded(`${a.label} was lost and no worker is left`)
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
private async listen(): Promise<void> {
|
|
1470
|
+
while (!this.stop.signal.aborted) {
|
|
1471
|
+
let messages
|
|
1472
|
+
try {
|
|
1473
|
+
// The client asks again through faults shorter than 10 s.
|
|
1474
|
+
messages = await this.o.me.poll(this.stop.signal)
|
|
1475
|
+
} catch (err) {
|
|
1476
|
+
if (this.stop.signal.aborted) return
|
|
1477
|
+
// A relay unreachable longer has lost this run. Its tasks fail, not
|
|
1478
|
+
// run here: this machine may be far smaller than the workers. A rerun
|
|
1479
|
+
// resumes from what finished and saved.
|
|
1480
|
+
this.fail(`the relay is unreachable (${(err as Error).message})`)
|
|
1481
|
+
return
|
|
1482
|
+
}
|
|
1483
|
+
for (const { from, body } of messages) {
|
|
1484
|
+
if (from === 'relay') {
|
|
1485
|
+
this.onRelay(body as { type?: string })
|
|
1486
|
+
continue
|
|
1487
|
+
}
|
|
1488
|
+
const a = this.workers.get(from)
|
|
1489
|
+
if (a === undefined) continue
|
|
1490
|
+
const m = body as ToOrchestrator
|
|
1491
|
+
if ('load' in m) this.heard(a, m.load)
|
|
1492
|
+
if (m.type !== 'held') this.movedAt = performance.now()
|
|
1493
|
+
if (m.type === 'start') {
|
|
1494
|
+
this.begun(a, m.task)
|
|
1495
|
+
this.pumpSoon()
|
|
1496
|
+
} else if (m.type === 'held') {
|
|
1497
|
+
for (const t of m.running) {
|
|
1498
|
+
const j = a.busy.get(t)
|
|
1499
|
+
if (j?.leaseUntil !== undefined) this.lease(j)
|
|
1500
|
+
this.begun(a, t)
|
|
1501
|
+
}
|
|
1502
|
+
this.pumpSoon()
|
|
1503
|
+
} else if (m.type === 'ran') {
|
|
1504
|
+
// Its command ended: its cores are the next task's while it saves.
|
|
1505
|
+
const j = a.busy.get(m.task)
|
|
1506
|
+
if (j === undefined) continue
|
|
1507
|
+
this.begun(a, m.task)
|
|
1508
|
+
this.unhold(a, j)
|
|
1509
|
+
// Its worker still says it holds it while it saves.
|
|
1510
|
+
j.ranAt = performance.now()
|
|
1511
|
+
this.pumpSoon()
|
|
1512
|
+
} else if (m.type === 'fault') {
|
|
1513
|
+
const j = a.busy.get(m.task)
|
|
1514
|
+
if (j === undefined) continue
|
|
1515
|
+
// It began it whether or not its `start` came first.
|
|
1516
|
+
this.begun(a, m.task)
|
|
1517
|
+
if (this.retry(a, j, m.reason) !== undefined) this.queue.unshift(j)
|
|
1518
|
+
if (a.faulty) this.o.faulted?.()
|
|
1519
|
+
if (a.faulty && this.spawnedAt.size > 0)
|
|
1520
|
+
this.stranded(`${a.label} could not run ${m.task} (${m.reason}) and no worker is left`)
|
|
1521
|
+
this.pumpSoon()
|
|
1522
|
+
} else if (m.type === 'part') {
|
|
1523
|
+
a.usage.messagesOut++
|
|
1524
|
+
a.usage.bytesOut += bytesOf(m)
|
|
1525
|
+
const k = `${from}\0${m.task}`
|
|
1526
|
+
const held = this.parts.get(k) ?? {}
|
|
1527
|
+
for (const list of PART_LISTS) {
|
|
1528
|
+
const items = m[list] as readonly unknown[] | undefined
|
|
1529
|
+
if (items !== undefined) held[list] = [...(held[list] ?? []), ...items]
|
|
1530
|
+
}
|
|
1531
|
+
this.parts.set(k, held)
|
|
1532
|
+
} else if (m.type === 'end') {
|
|
1533
|
+
a.usage.messagesOut++
|
|
1534
|
+
a.usage.bytesOut += bytesOf(m)
|
|
1535
|
+
const k = `${from}\0${m.task}`
|
|
1536
|
+
const held = this.parts.get(k)
|
|
1537
|
+
this.parts.delete(k)
|
|
1538
|
+
const result: Record<string, unknown> = { ...m.result }
|
|
1539
|
+
for (const list of PART_LISTS) {
|
|
1540
|
+
const items = m.result[list] as readonly unknown[] | undefined
|
|
1541
|
+
if (held?.[list] !== undefined) result[list] = [...held[list], ...(items ?? [])]
|
|
1542
|
+
}
|
|
1543
|
+
// Not awaited: one task's restore holds no other message.
|
|
1544
|
+
void this.finish(a, m.task, m.key, result as WiredResult)
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
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
|
+
this.stall()
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/** The worker running a task; `null` while it waits, here or in a worker's queue, for one to begin it. */
|
|
1556
|
+
where(taskId: string): string | null | undefined {
|
|
1557
|
+
for (const a of this.workers.values())
|
|
1558
|
+
if (a.busy.has(taskId)) return a.started.has(taskId) ? a.label : null
|
|
1559
|
+
return this.queue.some((j) => j.req.taskId === taskId) ? null : undefined
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/**
|
|
1563
|
+
* Core's cache probe answered: `left` placed tasks no cache holds or none
|
|
1564
|
+
* answered for, `misses` of them found in none. Said in the footer: main
|
|
1565
|
+
* d0cdc2c asked 15 workers for 119 tasks, 91 restored, and no line said
|
|
1566
|
+
* what the probe had answered (2026-10-23).
|
|
1567
|
+
*/
|
|
1568
|
+
probed(left: number, misses: number): void {
|
|
1569
|
+
if (this.probe !== undefined) return
|
|
1570
|
+
this.probe = { at: performance.now() - this.startedAt, left, misses }
|
|
1571
|
+
this.trace.mark(ORCHESTRATOR, 'cache probe', { remaining: left, misses })
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/** The run asks for `workers` more for `tasks` it knows miss, its plan ending in `endsInMs`. */
|
|
1575
|
+
asked(
|
|
1576
|
+
workers: number,
|
|
1577
|
+
tasks: number,
|
|
1578
|
+
measured: number,
|
|
1579
|
+
endsInMs: number,
|
|
1580
|
+
unmeasured: readonly string[] = [],
|
|
1581
|
+
): void {
|
|
1582
|
+
if (this.asks.length === 0) this.unmeasured = unmeasured
|
|
1583
|
+
this.asks.push({ at: performance.now() - this.startedAt, workers, tasks, measured, endsInMs })
|
|
1584
|
+
this.trace.mark(ORCHESTRATOR, `ask ${workers} worker${workers === 1 ? '' : 's'}`, {
|
|
1585
|
+
tasks,
|
|
1586
|
+
measured,
|
|
1587
|
+
planEndsInMs: Math.round(endsInMs),
|
|
1588
|
+
})
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* A plan that started or let go of workers, in the profile: what it saw
|
|
1593
|
+
* of each worker (cores busy / ms left per task, or when it joins) and
|
|
1594
|
+
* of the work, so a worker asked and cancelled a second later can be
|
|
1595
|
+
* read back (PR #4214's run: 7, 2026-10-23).
|
|
1596
|
+
*/
|
|
1597
|
+
planned(p: {
|
|
1598
|
+
start: readonly string[]
|
|
1599
|
+
release: readonly string[]
|
|
1600
|
+
tasks: number
|
|
1601
|
+
endsInMs: number
|
|
1602
|
+
boundMs: number
|
|
1603
|
+
workers: readonly PlanWorker[]
|
|
1604
|
+
/**
|
|
1605
|
+
* A plan that starts workers: the work it played (seconds, cores,
|
|
1606
|
+
* platform) and each size's warm-up, so it can be played back. Main
|
|
1607
|
+
* d8a795c planned 38 s with one Mac for a Mac lane of 88 s, and
|
|
1608
|
+
* nothing in the profile said what it thought each task took.
|
|
1609
|
+
*/
|
|
1610
|
+
work?: readonly PlanTask[]
|
|
1611
|
+
warmUps?: ReadonlyMap<string, number>
|
|
1612
|
+
}): void {
|
|
1613
|
+
const work = p.work?.map(
|
|
1614
|
+
(t) =>
|
|
1615
|
+
`${t.id} ${t.ms === undefined ? '?' : Math.round(t.ms / 100) / 10}s ${Math.round(t.cores * 100) / 100}c${t.platform === undefined ? '' : ` ${t.platform}`}`,
|
|
1616
|
+
)
|
|
1617
|
+
const workers = p.workers.map((w) =>
|
|
1618
|
+
w.starting === true
|
|
1619
|
+
? `${w.id} joins ${Math.round(w.at)}`
|
|
1620
|
+
: `${w.id} ${w.running.map((r) => `${Math.round(r.cores * 10) / 10}c/${Math.round(r.endsIn)}`).join(' ') || 'idle'}`,
|
|
1621
|
+
)
|
|
1622
|
+
this.trace.mark(ORCHESTRATOR, 'plan', {
|
|
1623
|
+
start: p.start.join(' '),
|
|
1624
|
+
release: p.release.join(' '),
|
|
1625
|
+
tasks: p.tasks,
|
|
1626
|
+
endsInMs: Math.round(p.endsInMs),
|
|
1627
|
+
boundMs: Math.round(p.boundMs),
|
|
1628
|
+
workers: workers.join(' · '),
|
|
1629
|
+
...(work === undefined ? {} : { work: work.join(' · ') }),
|
|
1630
|
+
...(p.warmUps === undefined
|
|
1631
|
+
? {}
|
|
1632
|
+
: {
|
|
1633
|
+
warmUps: [...p.warmUps]
|
|
1634
|
+
.map(([s, ms]) => `${s} ${Math.round(ms / 100) / 10}s`)
|
|
1635
|
+
.join(' · '),
|
|
1636
|
+
}),
|
|
1637
|
+
})
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/** `demand` asked the adapter for worker `id` (`WorkerSpawn.id`). */
|
|
1641
|
+
spawned(id: string, declared: Declared): void {
|
|
1642
|
+
// A worker whose start broke where no adapter sees it counted as
|
|
1643
|
+
// starting for good: main's run sat 18 min (ee3fc6e, 2026-10-23). Its
|
|
1644
|
+
// job ending says so (`failed`); one that never ends is given up past
|
|
1645
|
+
// its size's join limit.
|
|
1646
|
+
const at = performance.now()
|
|
1647
|
+
this.spawnedAt.set(id, { at, declared })
|
|
1648
|
+
this.deadline(id, at, declared.size)
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
/** Worker `id`, started at `at`, is given up once its size's join limit passes unjoined. */
|
|
1652
|
+
private deadline(id: string, at: number, size: string): void {
|
|
1653
|
+
const ms = this.joinLimitMs.get(size)
|
|
1654
|
+
if (ms === undefined) return
|
|
1655
|
+
setTimeout(
|
|
1656
|
+
() => {
|
|
1657
|
+
if (this.ended || this.cancelled.has(id) || this.unstarted.has(id)) return
|
|
1658
|
+
if ([...this.workers.values(), ...this.gone].some((a) => a.label === id)) return
|
|
1659
|
+
this.cancelled.add(id)
|
|
1660
|
+
this.late.add(id)
|
|
1661
|
+
this.o.overdue?.(id)
|
|
1662
|
+
this.failed(id, `did not join within ${Math.round(ms / 1000)} s`, true)
|
|
1663
|
+
},
|
|
1664
|
+
Math.max(0, at + ms - performance.now()),
|
|
1665
|
+
).unref()
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* The shortest time a worker this run asked for took to join, once one
|
|
1670
|
+
* has: what a worker asked for now takes, at the least.
|
|
1671
|
+
*/
|
|
1672
|
+
warmUpMs(): number | undefined {
|
|
1673
|
+
let least: number | undefined
|
|
1674
|
+
for (const a of [...this.workers.values(), ...this.gone])
|
|
1675
|
+
if (a.warmUpMs !== undefined && !(least! <= a.warmUpMs)) least = a.warmUpMs
|
|
1676
|
+
return least
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/** Each task a worker ran, how long it held the worker, by `forecast`'s ms (`slowdown`). */
|
|
1680
|
+
finished(
|
|
1681
|
+
forecast: (id: string) => number | undefined,
|
|
1682
|
+
): { took: number; forecast: number | undefined }[] {
|
|
1683
|
+
return [...this.took].map(([id, t]) => ({ took: t.ms, forecast: forecast(id) }))
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/** `warmUpMs` of each size a worker this run started joined as. */
|
|
1687
|
+
warmUpsMs(): Map<string, number> {
|
|
1688
|
+
const out = new Map<string, number>()
|
|
1689
|
+
for (const a of [...this.workers.values(), ...this.gone]) {
|
|
1690
|
+
const size = a.declared?.size
|
|
1691
|
+
if (a.warmUpMs === undefined || size === undefined) continue
|
|
1692
|
+
if (!(out.get(size)! <= a.warmUpMs)) out.set(size, a.warmUpMs)
|
|
1693
|
+
}
|
|
1694
|
+
return out
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* The workers the plan places on (`planner.ts`): each online, what it
|
|
1699
|
+
* runs and how long that has left by `ms`, and each still starting,
|
|
1700
|
+
* joining after its size's `warmUpMs` from its ask. With them, the tasks they run.
|
|
1701
|
+
*/
|
|
1702
|
+
planState(
|
|
1703
|
+
ms: (taskId: string) => number | undefined,
|
|
1704
|
+
warmUpMs: WarmUp,
|
|
1705
|
+
): { workers: PlanWorker[]; running: Set<string> } {
|
|
1706
|
+
const now = performance.now()
|
|
1707
|
+
const running = new Set<string>()
|
|
1708
|
+
const workers: PlanWorker[] = []
|
|
1709
|
+
for (const a of this.workers.values()) {
|
|
1710
|
+
if (a.faulty) continue
|
|
1711
|
+
const jobs = [...a.busy.values()].filter((j) => j.startedAt !== undefined)
|
|
1712
|
+
for (const j of jobs) running.add(j.req.taskId)
|
|
1713
|
+
// Its tasks use more than their history said (a suite given more
|
|
1714
|
+
// workers): the plan holds them at what the load says, this run.
|
|
1715
|
+
const over = a.usedCores > 0 ? Math.max(1, this.used(a).cores / a.usedCores) : 1
|
|
1716
|
+
workers.push({
|
|
1717
|
+
id: a.id,
|
|
1718
|
+
cores: a.cores,
|
|
1719
|
+
memory: a.memory,
|
|
1720
|
+
platform: a.platform,
|
|
1721
|
+
...(a.declared === undefined ? {} : { size: a.declared.size }),
|
|
1722
|
+
at: 0,
|
|
1723
|
+
idle: a.busy.size === 0,
|
|
1724
|
+
// An unmeasured one counts as it did waiting (`ask`), not as the
|
|
1725
|
+
// one core it holds: started, it freed a core the plan then found
|
|
1726
|
+
// spare, and the worker asked for it was cancelled (cold main
|
|
1727
|
+
// bcc84f1: 11 of 25, 2026-10-23).
|
|
1728
|
+
running: jobs.map((j) => ({
|
|
1729
|
+
id: j.req.taskId,
|
|
1730
|
+
cores: Math.min(a.cores, Math.max((j.held?.cores ?? 0) * over, j.need.ask?.cores ?? 0)),
|
|
1731
|
+
memory: Math.max(j.held?.memory ?? 0, Math.min(a.memory, j.need.ask?.memory ?? 0)),
|
|
1732
|
+
endsIn: Math.max(0, (ms(j.req.taskId) ?? 0) - (now - j.startedAt!)),
|
|
1733
|
+
})),
|
|
1734
|
+
})
|
|
1735
|
+
}
|
|
1736
|
+
const joined = new Set([...this.workers.values(), ...this.gone].map((a) => a.label))
|
|
1737
|
+
for (const [id, { at, declared }] of this.spawnedAt)
|
|
1738
|
+
if (!joined.has(id) && !this.unstarted.has(id) && !this.cancelled.has(id))
|
|
1739
|
+
workers.push({
|
|
1740
|
+
id,
|
|
1741
|
+
cores: declared.cpu,
|
|
1742
|
+
memory: declared.memory,
|
|
1743
|
+
// A size naming none is planned as this machine's, as `dte()`
|
|
1744
|
+
// planned it: on no platform, no task naming one fit it, and the
|
|
1745
|
+
// next plan cancelled it.
|
|
1746
|
+
platform: declared.platform ?? hostPlatform(),
|
|
1747
|
+
size: declared.size,
|
|
1748
|
+
at: Math.max(
|
|
1749
|
+
0,
|
|
1750
|
+
(typeof warmUpMs === 'number' ? warmUpMs : warmUpMs(declared.size)) - (now - at),
|
|
1751
|
+
),
|
|
1752
|
+
idle: false,
|
|
1753
|
+
running: [],
|
|
1754
|
+
// One its adapter could not stop joins whatever the plan says.
|
|
1755
|
+
...(this.unstoppable.has(id) ? {} : { starting: true as const }),
|
|
1756
|
+
})
|
|
1757
|
+
return { workers, running }
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* Sizes the CI has not started a worker of past its warm-up, `warmUpMs`
|
|
1762
|
+
* from its ask: one given up as late counts while one of its size is
|
|
1763
|
+
* online. A worker asked now queues behind it: PR 2f0e2a4's run asked 7
|
|
1764
|
+
* more while GitHub queued its first 11 for 30-85 s; none got a runner
|
|
1765
|
+
* before its join limit cancelled it (2026-10-24).
|
|
1766
|
+
*/
|
|
1767
|
+
queued(warmUpMs: (size: string) => number): Set<string> {
|
|
1768
|
+
const now = performance.now()
|
|
1769
|
+
const online = new Set([...this.workers.values()].map((a) => a.declared?.size))
|
|
1770
|
+
const joined = new Set([...this.workers.values(), ...this.gone].map((a) => a.label))
|
|
1771
|
+
const out = new Set<string>()
|
|
1772
|
+
for (const [id, { at, declared }] of this.spawnedAt) {
|
|
1773
|
+
if (joined.has(id)) continue
|
|
1774
|
+
if (
|
|
1775
|
+
this.late.has(id)
|
|
1776
|
+
? online.has(declared.size)
|
|
1777
|
+
: !this.cancelled.has(id) && !this.unstarted.has(id) && now - at > warmUpMs(declared.size)
|
|
1778
|
+
)
|
|
1779
|
+
out.add(declared.size)
|
|
1780
|
+
}
|
|
1781
|
+
return out
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* Workers `ids` leave (`exit`) if still idle, and each faulty idle one:
|
|
1786
|
+
* none comes back. Of `ids`, each started and not joined is cancelled:
|
|
1787
|
+
* the ones returned, for the adapter to stop; one that joins anyway is
|
|
1788
|
+
* told to exit at once.
|
|
1789
|
+
*/
|
|
1790
|
+
release(ids: Iterable<string>): string[] {
|
|
1791
|
+
for (const a of this.workers.values()) if (a.faulty && a.busy.size === 0) this.letGo(a)
|
|
1792
|
+
const joined = new Set([...this.workers.values(), ...this.gone].map((a) => a.label))
|
|
1793
|
+
const cancelled: string[] = []
|
|
1794
|
+
for (const id of ids) {
|
|
1795
|
+
const a = this.workers.get(id)
|
|
1796
|
+
if (a !== undefined) {
|
|
1797
|
+
if (a.busy.size === 0) this.letGo(a)
|
|
1798
|
+
} else if (
|
|
1799
|
+
this.spawnedAt.has(id) &&
|
|
1800
|
+
!joined.has(id) &&
|
|
1801
|
+
!this.unstarted.has(id) &&
|
|
1802
|
+
!this.unstoppable.has(id)
|
|
1803
|
+
) {
|
|
1804
|
+
this.cancelled.add(id)
|
|
1805
|
+
cancelled.push(id)
|
|
1806
|
+
this.trace.unstarted(id, this.spawnedAt.get(id)!.at, 'cancelled: not needed')
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
return cancelled
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/**
|
|
1813
|
+
* Of `ids`, let go before they joined, those its adapter could not stop
|
|
1814
|
+
* (`DteAdapter.cancel`): each counts as starting again and is taken when
|
|
1815
|
+
* it joins. The ones returned; one told to exit already is gone.
|
|
1816
|
+
*/
|
|
1817
|
+
leftRunning(ids: Iterable<string>): string[] {
|
|
1818
|
+
const out: string[] = []
|
|
1819
|
+
for (const id of ids)
|
|
1820
|
+
if (this.cancelled.has(id) && !this.late.has(id) && !this.turnedAway.has(id)) {
|
|
1821
|
+
this.cancelled.delete(id)
|
|
1822
|
+
this.unstoppable.add(id)
|
|
1823
|
+
out.push(id)
|
|
1824
|
+
}
|
|
1825
|
+
return out
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
private letGo(a: Worker): void {
|
|
1829
|
+
this.workers.delete(a.id)
|
|
1830
|
+
a.usage.to = performance.now()
|
|
1831
|
+
this.trace.left(a.label, 'left', a.usage.to)
|
|
1832
|
+
this.gone.push(a)
|
|
1833
|
+
this.retired.add(a)
|
|
1834
|
+
this.post(a, { type: 'exit' })
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* Worker `id` will not join: its start failed or it exited first. One that
|
|
1839
|
+
* joined is lost now, not when the relay stops hearing it (20 s, its tasks
|
|
1840
|
+
* waiting).
|
|
1841
|
+
*/
|
|
1842
|
+
failed(id: string, reason: string, overdue = false): void {
|
|
1843
|
+
// Its job ended: one given up as late no longer joins after all.
|
|
1844
|
+
if (!overdue) this.late.delete(id)
|
|
1845
|
+
if (this.ended || (this.cancelled.has(id) && !overdue)) return
|
|
1846
|
+
const live = [...this.workers.values()].find((a) => a.label === id)
|
|
1847
|
+
if (live !== undefined) {
|
|
1848
|
+
this.lose(live)
|
|
1849
|
+
this.pumpSoon()
|
|
1850
|
+
return
|
|
1851
|
+
}
|
|
1852
|
+
// Gone already: its job ending says nothing new.
|
|
1853
|
+
if (this.gone.some((a) => a.label === id) || this.unstarted.has(id)) return
|
|
1854
|
+
this.unstarted.set(id, reason)
|
|
1855
|
+
const asked = this.spawnedAt.get(id)?.at
|
|
1856
|
+
if (asked !== undefined) this.trace.unstarted(id, asked, reason)
|
|
1857
|
+
// A checkout, an install or a join can fail on the network; a few more
|
|
1858
|
+
// workers get a go. Failing that often, the setup is broken: none more.
|
|
1859
|
+
if (this.restarts < RESTARTS) {
|
|
1860
|
+
this.restarts++
|
|
1861
|
+
this.o.lost?.(id)
|
|
1862
|
+
}
|
|
1863
|
+
this.stranded(`no worker could start: ${id} ${reason}`)
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/** Nothing online that takes work, none still starting: no task can run. */
|
|
1867
|
+
private stranded(reason: string): void {
|
|
1868
|
+
const joined = new Set([...this.workers.values(), ...this.gone].map((a) => a.label))
|
|
1869
|
+
const starting = [...this.spawnedAt.keys()].some(
|
|
1870
|
+
(s) => !this.unstarted.has(s) && !this.cancelled.has(s) && !joined.has(s),
|
|
1871
|
+
)
|
|
1872
|
+
const working = [...this.workers.values()].some((a) => !a.faulty)
|
|
1873
|
+
if (!working && !starting) this.fail(reason)
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/** Every task sent here and not done fails with `reason`; later ones too. */
|
|
1877
|
+
private fail(reason: string): void {
|
|
1878
|
+
for (const a of this.workers.values())
|
|
1879
|
+
for (const j of a.busy.values()) j.reject(new Error(reason))
|
|
1880
|
+
for (const j of this.queue.splice(0)) j.reject(new Error(reason))
|
|
1881
|
+
this.workers.clear()
|
|
1882
|
+
this.lost = reason
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* `left` when the run let it go as not needed, `lost` when it crashed or
|
|
1887
|
+
* went silent, `stopped` when the run's end stopped it, else `online`.
|
|
1888
|
+
*/
|
|
1889
|
+
private state(a: Worker, gone: ReadonlySet<Worker>): string {
|
|
1890
|
+
if (this.retired.has(a)) return 'left'
|
|
1891
|
+
if (gone.has(a)) return 'lost'
|
|
1892
|
+
return this.ended ? 'stopped' : 'online'
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* The run's end had it placed its tasks perfectly (2026-10-23: "how
|
|
1897
|
+
* close the run was"): each task a worker ran, as long as it held its
|
|
1898
|
+
* worker and with the cores it used meanwhile, played on as
|
|
1899
|
+
* many workers as the sizes allow, each joining after its platform's
|
|
1900
|
+
* fastest real warm-up, from when the run first knew its work. A task
|
|
1901
|
+
* waits on its deps through those no worker ran (groups, hits).
|
|
1902
|
+
*/
|
|
1903
|
+
idealMs(depsOf: (id: string) => readonly string[], sizes: readonly Size[]): number | undefined {
|
|
1904
|
+
if (this.took.size === 0) return undefined
|
|
1905
|
+
const all = [...this.workers.values(), ...this.gone]
|
|
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
|
|
1911
|
+
const pool: PlanWorker[] = (
|
|
1912
|
+
sizes.length > 0
|
|
1913
|
+
? sizes.flatMap((s) =>
|
|
1914
|
+
Array.from({ length: Math.min(s.max, this.took.size) }, () => ({
|
|
1915
|
+
cores: s.cpu,
|
|
1916
|
+
memory: s.memory,
|
|
1917
|
+
platform: s.platform,
|
|
1918
|
+
})),
|
|
1919
|
+
)
|
|
1920
|
+
: all.map((a) => ({ cores: a.cores, memory: a.memory, platform: a.platform }))
|
|
1921
|
+
).map((w, i) => ({
|
|
1922
|
+
...w,
|
|
1923
|
+
id: `ideal#${i}`,
|
|
1924
|
+
at: warm.get(w.platform) ?? least,
|
|
1925
|
+
running: [],
|
|
1926
|
+
idle: false,
|
|
1927
|
+
}))
|
|
1928
|
+
const seen = new Map<string, string[]>()
|
|
1929
|
+
const ran = (id: string): string[] => {
|
|
1930
|
+
const known = seen.get(id)
|
|
1931
|
+
if (known !== undefined) return known
|
|
1932
|
+
seen.set(id, [])
|
|
1933
|
+
const out = depsOf(id).flatMap((d) => (this.took.has(d) ? [d] : ran(d)))
|
|
1934
|
+
seen.set(id, out)
|
|
1935
|
+
return out
|
|
1936
|
+
}
|
|
1937
|
+
const tasks: PlanTask[] = [...this.took].map(([id, t]) => ({ id, ...t, deps: ran(id) }))
|
|
1938
|
+
const end = simulate(tasks, pool)
|
|
1939
|
+
if (!Number.isFinite(end)) return undefined
|
|
1940
|
+
return (this.probe?.at ?? this.asks[0]?.at ?? 0) + end
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Each machine that joined, and this one when it ran tasks, with what it
|
|
1945
|
+
* did: the run summary's `machines`. Core asks once its tasks are over,
|
|
1946
|
+
* before its teardown ends this run: then is its end, so one still here
|
|
1947
|
+
* was online until now (small#2 read online 1ms, booked 433802 of 4:
|
|
1948
|
+
* main, 2026-10-23). Each is `ok`, or `failed` when lost or faulty.
|
|
1949
|
+
*/
|
|
1950
|
+
machines(): MachineReport[] {
|
|
1951
|
+
const gone = new Set(this.gone)
|
|
1952
|
+
const until = this.ended ? this.endedAt : performance.now()
|
|
1953
|
+
const report = (
|
|
1954
|
+
name: string,
|
|
1955
|
+
status: string,
|
|
1956
|
+
u: Usage,
|
|
1957
|
+
cores: number,
|
|
1958
|
+
memoryBytes: number,
|
|
1959
|
+
): MachineReport => {
|
|
1960
|
+
const st = u.stats(cores, until)
|
|
1961
|
+
return {
|
|
1962
|
+
name,
|
|
1963
|
+
status,
|
|
1964
|
+
cores,
|
|
1965
|
+
memoryBytes,
|
|
1966
|
+
done: u.done,
|
|
1967
|
+
failed: u.failed,
|
|
1968
|
+
onlineMs: st.onlineMs,
|
|
1969
|
+
busyMs: st.busyMs,
|
|
1970
|
+
cpuPeak: st.cpuPeak,
|
|
1971
|
+
cpuAvg: st.cpuAvg,
|
|
1972
|
+
memoryPeakBytes: st.memoryPeak,
|
|
1973
|
+
memoryAvgBytes: st.memoryAvg,
|
|
1974
|
+
utilization: st.utilization,
|
|
1975
|
+
bookedAvg: st.bookedAvg,
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
return [...this.workers.values(), ...this.gone].map((a) => ({
|
|
1979
|
+
// Whether it failed or ran its part, not why it left (2026-10-23).
|
|
1980
|
+
...report(
|
|
1981
|
+
a.label,
|
|
1982
|
+
(gone.has(a) && !this.retired.has(a)) || a.faulty ? 'failed' : 'ok',
|
|
1983
|
+
a.usage,
|
|
1984
|
+
a.reported.cores,
|
|
1985
|
+
a.reported.memory,
|
|
1986
|
+
),
|
|
1987
|
+
...(a.warmUpMs === undefined ? {} : { warmUpMs: a.warmUpMs }),
|
|
1988
|
+
startMs: Math.max(0, a.usage.from - (a.warmUpMs ?? 0) - this.startedAt),
|
|
1989
|
+
}))
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/**
|
|
1993
|
+
* The `workers` rows, live and at the end: one per worker by `size#index`
|
|
1994
|
+
* with its status, the tasks it has done and the ones it runs now, then
|
|
1995
|
+
* this machine's; once over, each joined worker's warm-up and, under
|
|
1996
|
+
* each, the cores and memory its size declares and its machine reports;
|
|
1997
|
+
* and `ideal`, the run's `idealMs`, asked once it ended.
|
|
1998
|
+
*/
|
|
1999
|
+
footer(ideal?: () => number | undefined): [string, ui.Inline][] {
|
|
2000
|
+
// Over with none, it says so: core's own `N workers` (this machine's
|
|
2001
|
+
// pool) read as the run's when no task needed one (2026-10-25).
|
|
2002
|
+
if (this.spawnedAt.size === 0 && this.workers.size === 0 && this.gone.length === 0)
|
|
2003
|
+
return this.ended ? [['workers', ui.dim('none started: no task needed one')]] : []
|
|
2004
|
+
const gone = new Set(this.gone)
|
|
2005
|
+
const all = [...this.workers.values(), ...this.gone]
|
|
2006
|
+
const joined = new Set(all.map((a) => a.label))
|
|
2007
|
+
const count = (u: Usage, running: number): string =>
|
|
2008
|
+
[
|
|
2009
|
+
`${u.done} done`,
|
|
2010
|
+
...(u.failed > 0 ? [`${u.failed} failed`] : []),
|
|
2011
|
+
...(u.aborted > 0 ? [`${u.aborted} aborted`] : []),
|
|
2012
|
+
...(running > 0 ? [`${running} running`] : []),
|
|
2013
|
+
].join(' · ')
|
|
2014
|
+
// Once over, under its row: what its size declares and what its machine
|
|
2015
|
+
// reports, green when they agree, red when not.
|
|
2016
|
+
const res = new Map<string, ui.Inline>()
|
|
2017
|
+
const resources = (label: string, cores: number, memory: number, d?: Declared): void => {
|
|
2018
|
+
const actual = `${cores}c/${compact(memory)}`
|
|
2019
|
+
res.set(
|
|
2020
|
+
label,
|
|
2021
|
+
d === undefined
|
|
2022
|
+
? ui.dim(`${indent}actual ${actual}`)
|
|
2023
|
+
: [
|
|
2024
|
+
ui.dim(`${indent}declared ${d.cpu}c/${compact(d.memory)} · actual `),
|
|
2025
|
+
ui.tone(matches(cores, memory, d) ? 'ok' : 'error', actual),
|
|
2026
|
+
],
|
|
2027
|
+
)
|
|
2028
|
+
}
|
|
2029
|
+
// Live, those that left are one line: a run that replaced many pushed
|
|
2030
|
+
// the rows that run past the screen.
|
|
2031
|
+
const left = this.ended ? [] : all.filter((a) => this.retired.has(a))
|
|
2032
|
+
const lines: [string, string, string][] = all
|
|
2033
|
+
.filter((a) => !left.includes(a))
|
|
2034
|
+
.map((a) => [
|
|
2035
|
+
a.label,
|
|
2036
|
+
this.state(a, gone),
|
|
2037
|
+
count(a.usage, gone.has(a) ? 0 : a.started.size) +
|
|
2038
|
+
(this.ended && a.warmUpMs !== undefined ? ` · warm-up ${seconds(a.warmUpMs)}` : '') +
|
|
2039
|
+
(this.ended && a.done.length > 0 && a.done.length <= FEW
|
|
2040
|
+
? ` · ${a.done.join(', ')}`
|
|
2041
|
+
: ''),
|
|
2042
|
+
])
|
|
2043
|
+
for (const id of this.spawnedAt.keys())
|
|
2044
|
+
if (!joined.has(id) && (!this.cancelled.has(id) || this.late.has(id))) {
|
|
2045
|
+
const why = this.unstarted.get(id)
|
|
2046
|
+
// Over, the run waits on none: one still starting then never joined.
|
|
2047
|
+
lines.push(
|
|
2048
|
+
why !== undefined
|
|
2049
|
+
? [id, 'failed', why]
|
|
2050
|
+
: this.ended
|
|
2051
|
+
? [id, 'not joined', 'the run ended first']
|
|
2052
|
+
: [id, 'starting', ''],
|
|
2053
|
+
)
|
|
2054
|
+
}
|
|
2055
|
+
lines.sort((x, y) => x[0].localeCompare(y[0], 'en', { numeric: true }))
|
|
2056
|
+
const cancelled = [...this.cancelled].filter(
|
|
2057
|
+
(id) => !joined.has(id) && !this.late.has(id),
|
|
2058
|
+
).length
|
|
2059
|
+
if (cancelled > 0)
|
|
2060
|
+
lines.push([`${cancelled} worker${cancelled === 1 ? '' : 's'}`, 'cancelled', 'not needed'])
|
|
2061
|
+
if (left.length > 0)
|
|
2062
|
+
lines.push([
|
|
2063
|
+
`${left.length} worker${left.length === 1 ? '' : 's'}`,
|
|
2064
|
+
'left',
|
|
2065
|
+
`${left.reduce((n, a) => n + a.usage.done, 0)} done`,
|
|
2066
|
+
])
|
|
2067
|
+
const width = Math.max(...lines.map(([l]) => l.length))
|
|
2068
|
+
const indent = ' '.repeat(width + 2)
|
|
2069
|
+
const status = Math.max(...lines.map(([, st]) => st.length))
|
|
2070
|
+
// Once over, what each did, under its line.
|
|
2071
|
+
const usage = new Map<string, readonly string[]>()
|
|
2072
|
+
let cpuMs = 0
|
|
2073
|
+
let coreMs = 0
|
|
2074
|
+
if (this.ended)
|
|
2075
|
+
for (const a of all) {
|
|
2076
|
+
resources(a.label, a.reported.cores, a.reported.memory, a.declared)
|
|
2077
|
+
const st = a.usage.stats(a.cores, this.endedAt)
|
|
2078
|
+
cpuMs += st.cpuMs
|
|
2079
|
+
coreMs += a.cores * st.onlineMs
|
|
2080
|
+
usage.set(a.label, describe(st, a.usage))
|
|
2081
|
+
}
|
|
2082
|
+
return [
|
|
2083
|
+
...lines.flatMap(([l, st, t], i): [string, ui.Inline][] => [
|
|
2084
|
+
[
|
|
2085
|
+
i === 0 ? 'workers' : '',
|
|
2086
|
+
st === 'failed'
|
|
2087
|
+
? [ui.place(l), `${' '.repeat(width - l.length)} `, ui.tone('error', st), ` ${t}`]
|
|
2088
|
+
: t === ''
|
|
2089
|
+
? [ui.place(l), `${' '.repeat(width - l.length)} ${st}`]
|
|
2090
|
+
: [ui.place(l), `${' '.repeat(width - l.length)} ${st.padEnd(status)} ${t}`],
|
|
2091
|
+
],
|
|
2092
|
+
...(res.has(l) ? [['', res.get(l)!] as [string, ui.Inline]] : []),
|
|
2093
|
+
...(usage.get(l) ?? []).map((d): [string, ui.Inline] => ['', ui.dim(`${indent}${d}`)]),
|
|
2094
|
+
]),
|
|
2095
|
+
...(this.ended && this.probe !== undefined
|
|
2096
|
+
? [
|
|
2097
|
+
[
|
|
2098
|
+
'',
|
|
2099
|
+
ui.dim(
|
|
2100
|
+
`${'probe'.padEnd(width)} at ${seconds(this.probe.at)}: ${plural(this.probe.left, 'task')} left to run, ${plural(this.probe.misses, 'known miss')}`,
|
|
2101
|
+
),
|
|
2102
|
+
] as [string, ui.Inline],
|
|
2103
|
+
]
|
|
2104
|
+
: []),
|
|
2105
|
+
...(this.ended && this.asks.length > 0
|
|
2106
|
+
? [
|
|
2107
|
+
[
|
|
2108
|
+
'',
|
|
2109
|
+
ui.dim(
|
|
2110
|
+
`${'asked'.padEnd(width)} ${this.asks
|
|
2111
|
+
.map(
|
|
2112
|
+
(x) =>
|
|
2113
|
+
`${x.workers} at ${seconds(x.at)} for ${plural(x.tasks, 'task')} (${x.measured} measured), plan ${seconds(x.endsInMs)}`,
|
|
2114
|
+
)
|
|
2115
|
+
.join(' · ')}`,
|
|
2116
|
+
),
|
|
2117
|
+
] as [string, ui.Inline],
|
|
2118
|
+
// Beside core's time row, how close the run came (2026-10-23).
|
|
2119
|
+
...((ms) =>
|
|
2120
|
+
ms === undefined
|
|
2121
|
+
? []
|
|
2122
|
+
: [
|
|
2123
|
+
[
|
|
2124
|
+
'',
|
|
2125
|
+
ui.dim(
|
|
2126
|
+
`${'ideal'.padEnd(width)} ${seconds(ms)}: each task as long as it took, placed perfectly`,
|
|
2127
|
+
),
|
|
2128
|
+
] as [string, ui.Inline],
|
|
2129
|
+
])(ideal?.()),
|
|
2130
|
+
...(this.unmeasured.length === 0
|
|
2131
|
+
? []
|
|
2132
|
+
: [
|
|
2133
|
+
[
|
|
2134
|
+
'',
|
|
2135
|
+
ui.dim(
|
|
2136
|
+
`${'unmeasured'.padEnd(width)} ${this.unmeasured.slice(0, NAMED).join(', ')}${this.unmeasured.length > NAMED ? ` and ${this.unmeasured.length - NAMED} more` : ''}`,
|
|
2137
|
+
),
|
|
2138
|
+
] as [string, ui.Inline],
|
|
2139
|
+
]),
|
|
2140
|
+
]
|
|
2141
|
+
: []),
|
|
2142
|
+
...(coreMs > 0
|
|
2143
|
+
? [
|
|
2144
|
+
[
|
|
2145
|
+
'',
|
|
2146
|
+
`${'total'.padEnd(width)} cpu utilization ${percent(cpuMs / coreMs)}${this.needMs < LEARN_MS ? '' : ` · tasks used ${percent(this.usedMs / this.needMs)} of their history`}`,
|
|
2147
|
+
] as [string, ui.Inline],
|
|
2148
|
+
]
|
|
2149
|
+
: []),
|
|
2150
|
+
]
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/** Why the relay was given up on; then every task runs here. */
|
|
2154
|
+
lost: string | undefined
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function result(r: WiredResult, log: readonly CapturedChunk[]): ExecuteResult {
|
|
2158
|
+
const { cpuShare: _, removed: __, unread: ___, unshared: ____, output: _____, ...rest } = r
|
|
2159
|
+
const violations = (r.violations ?? []).map((v) => ({ ...v, timestamp: new Date(v.timestamp) }))
|
|
2160
|
+
const of = (err: boolean) =>
|
|
2161
|
+
log
|
|
2162
|
+
.filter((c) => c.err === err)
|
|
2163
|
+
.map((c) => c.text)
|
|
2164
|
+
.join('')
|
|
2165
|
+
return { ...rest, stdout: of(false), stderr: of(true), violations, outputs: { kind: 'disk' } }
|
|
2166
|
+
}
|
|
2167
|
+
|
|
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
|
+
/**
|
|
2181
|
+
* A worker's `cpuShare`, read at the boundary: in (0, 1], else 1. A twentieth
|
|
2182
|
+
* at least: a share that small is a misread, and dividing by it would book
|
|
2183
|
+
* a task a whole machine.
|
|
2184
|
+
*/
|
|
2185
|
+
function shareOf(share: unknown): number {
|
|
2186
|
+
return typeof share === 'number' && share > 0 && share <= 1 ? Math.max(0.05, share) : 1
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/** What a task that is not a cache hit to be is saved under: of this run, task and send, so no lookup ever finds it. */
|
|
2190
|
+
export function runKey(run: string, task: string, send = 0): string {
|
|
2191
|
+
const id = send === 0 ? `vx-dte\0${run}\0${task}` : `vx-dte\0${run}\0${task}\0${send}`
|
|
2192
|
+
return Bun.hash.xxHash3(id).toString(16).padStart(16, '0')
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* The projects inside `dir`, root-relative, once per graph and project:
|
|
2197
|
+
* read for each order, a run of 9000 tasks walked every task 9000 times
|
|
2198
|
+
* (2026-10-25).
|
|
2199
|
+
*/
|
|
2200
|
+
export function nestedIn(
|
|
2201
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
2202
|
+
dir: string,
|
|
2203
|
+
root: string,
|
|
2204
|
+
): string[] {
|
|
2205
|
+
let of = nested.get(nodes)
|
|
2206
|
+
if (of === undefined) {
|
|
2207
|
+
of = { dirs: [...new Set([...nodes.values()].map((n) => n.projectDir))], by: new Map() }
|
|
2208
|
+
nested.set(nodes, of)
|
|
2209
|
+
}
|
|
2210
|
+
let out = of.by.get(dir)
|
|
2211
|
+
if (out === undefined) {
|
|
2212
|
+
out = of.dirs
|
|
2213
|
+
.filter((d) => d.startsWith(dir + path.sep))
|
|
2214
|
+
.map((d) => path.relative(root, d))
|
|
2215
|
+
.sort()
|
|
2216
|
+
of.by.set(dir, out)
|
|
2217
|
+
}
|
|
2218
|
+
return [...out]
|
|
2219
|
+
}
|
|
2220
|
+
const nested = new WeakMap<
|
|
2221
|
+
ReadonlyMap<string, TaskNode>,
|
|
2222
|
+
{ dirs: string[]; by: Map<string, readonly string[]> }
|
|
2223
|
+
>()
|
|
2224
|
+
|
|
2225
|
+
/** A tree's file paths, and its submodules' (gitlinks). */
|
|
2226
|
+
type Tree = { files: Set<string>; links: string[] }
|
|
2227
|
+
|
|
2228
|
+
/** `tree`'s files and submodules under `root`, relative to it; undefined when git cannot list it. */
|
|
2229
|
+
async function listTree(root: string, tree: string): Promise<Tree | undefined> {
|
|
2230
|
+
const p = Bun.spawn(['git', 'ls-tree', '-r', '-z', tree], {
|
|
2231
|
+
cwd: root,
|
|
2232
|
+
stdin: 'ignore',
|
|
2233
|
+
stdout: 'pipe',
|
|
2234
|
+
stderr: 'ignore',
|
|
2235
|
+
})
|
|
2236
|
+
const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited])
|
|
2237
|
+
if (code !== 0) return undefined
|
|
2238
|
+
const files = new Set<string>()
|
|
2239
|
+
const links: string[] = []
|
|
2240
|
+
for (const e of out.split('\0')) {
|
|
2241
|
+
const tab = e.indexOf('\t')
|
|
2242
|
+
if (tab === -1) continue
|
|
2243
|
+
const name = e.slice(tab + 1)
|
|
2244
|
+
if (e.startsWith('160000 ')) links.push(name)
|
|
2245
|
+
else files.add(name)
|
|
2246
|
+
}
|
|
2247
|
+
return { files, links }
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/** Each distinct `exec.sandbox` of the run, for a worker's sandbox runtime. */
|
|
2251
|
+
function sandboxConfigs(nodes: ReadonlyMap<string, TaskNode> | undefined): SandboxConfig[] {
|
|
2252
|
+
const seen = new Map<string, SandboxConfig>()
|
|
2253
|
+
for (const n of nodes?.values() ?? []) {
|
|
2254
|
+
const s = n.config.exec?.sandbox
|
|
2255
|
+
if (s !== undefined) seen.set(JSON.stringify(s), s)
|
|
2256
|
+
}
|
|
2257
|
+
return [...seen.values()]
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/**
|
|
2261
|
+
* An uncached task that may leave files its dependants read: no cache
|
|
2262
|
+
* carries them. A sandboxed one writes only where its grants say; with
|
|
2263
|
+
* none (a gate like a lint or a version check) it leaves nothing, and
|
|
2264
|
+
* pinning every dependant to its worker held a run's shards on one.
|
|
2265
|
+
*/
|
|
2266
|
+
function leaves(node: TaskNode): boolean {
|
|
2267
|
+
if (node.config.cache !== undefined) return false
|
|
2268
|
+
const sandbox = node.config.exec?.sandbox
|
|
2269
|
+
return sandbox === undefined || (sandbox.allow?.write?.length ?? 0) > 0
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/**
|
|
2273
|
+
* Every task `id` depends on, transitively, each after its own
|
|
2274
|
+
* dependencies: the order a worker restores them in. Dependant first, an
|
|
2275
|
+
* additive task's rewrite of its upstream's file was undone by the
|
|
2276
|
+
* upstream's restore, and its dependants read the old bytes (2026-10-25).
|
|
2277
|
+
*/
|
|
2278
|
+
function upstream(nodes: ReadonlyMap<string, TaskNode> | undefined, id: string): Set<string> {
|
|
2279
|
+
const seen = new Set<string>()
|
|
2280
|
+
const out = new Set<string>()
|
|
2281
|
+
const walk = (t: string): void => {
|
|
2282
|
+
for (const d of nodes?.get(t)?.deps ?? []) {
|
|
2283
|
+
if (seen.has(d)) continue
|
|
2284
|
+
seen.add(d)
|
|
2285
|
+
walk(d)
|
|
2286
|
+
out.add(d)
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
walk(id)
|
|
2290
|
+
return out
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
/** A worker's run, spelled out: time, CPU, memory and the relay. */
|
|
2294
|
+
function describe(st: UsageStats, u: Usage): string[] {
|
|
2295
|
+
return [
|
|
2296
|
+
`online ${seconds(st.onlineMs)} · busy ${seconds(st.busyMs)} · idle ${seconds(Math.max(0, st.onlineMs - st.busyMs))}`,
|
|
2297
|
+
`cpu peak ${st.cpuPeak.toFixed(1)} · used avg ${st.cpuAvg.toFixed(1)} cores${st.machineAvg === undefined ? '' : ` · machine avg ${st.machineAvg.toFixed(1)}`} · booked avg ${st.bookedAvg.toFixed(1)} · cpu utilization ${percent(st.utilization)}`,
|
|
2298
|
+
// No task said what it held: `memory peak 0 B` would be a claim.
|
|
2299
|
+
...(st.memoryPeak > 0
|
|
2300
|
+
? [`memory peak ${bytes(st.memoryPeak)} · avg ${bytes(st.memoryAvg)}`]
|
|
2301
|
+
: []),
|
|
2302
|
+
`sent ${plural(u.messagesIn, 'message')} · ${bytes(u.bytesIn)}, received ${plural(u.messagesOut, 'message')} · ${bytes(u.bytesOut)}`,
|
|
2303
|
+
]
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
const plural = (n: number, w: string): string =>
|
|
2307
|
+
`${n} ${w}${n === 1 ? '' : w.endsWith('s') ? 'es' : 's'}`
|
|
2308
|
+
const percent = (n: number): string => `${Math.round(n * 100)}%`
|
|
2309
|
+
const bytes = (n: number): string =>
|
|
2310
|
+
n >= 1e9
|
|
2311
|
+
? `${(n / 1e9).toFixed(1)} GB`
|
|
2312
|
+
: n >= 1e6
|
|
2313
|
+
? `${(n / 1e6).toFixed(1)} MB`
|
|
2314
|
+
: n >= 1e3
|
|
2315
|
+
? `${Math.round(n / 1e3)} KB`
|
|
2316
|
+
: `${n} B`
|
|
2317
|
+
|
|
2318
|
+
/** A machine is what its size says: the same cores, memory within a tenth. */
|
|
2319
|
+
const matches = (cores: number, memory: number, d: Declared): boolean =>
|
|
2320
|
+
cores === d.cpu && Math.abs(memory - d.memory) <= d.memory * 0.1
|
|
2321
|
+
|
|
2322
|
+
/** Bytes as whole gigabytes, one decimal under 10: `16G`, `0.5G`. */
|
|
2323
|
+
const compact = (n: number): string => {
|
|
2324
|
+
const g = n / 1e9
|
|
2325
|
+
return `${g >= 10 ? Math.round(g) : Number(g.toFixed(1))}G`
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
|
|
2329
|
+
|
|
2330
|
+
/** The probes a task's key folds: a worker that answers otherwise fails it (C-2). */
|
|
2331
|
+
function runtimeOrders(req: ExecuteRequest): { runtime?: RuntimeOrder[] } {
|
|
2332
|
+
const inputs = req.inputs
|
|
2333
|
+
if (inputs === undefined) return {}
|
|
2334
|
+
const runtime: RuntimeOrder[] = [
|
|
2335
|
+
...inputs.runtime.map((r) => ({ command: r.command, output: r.output })),
|
|
2336
|
+
...inputs.workspaceRuntime.map((r) => ({
|
|
2337
|
+
command: r.command,
|
|
2338
|
+
output: r.output,
|
|
2339
|
+
workspace: true as const,
|
|
2340
|
+
})),
|
|
2341
|
+
]
|
|
2342
|
+
return runtime.length === 0 ? {} : { runtime }
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
/**
|
|
2346
|
+
* `env` as it travels: no unset name, and no name whose value is the
|
|
2347
|
+
* worker's (`HOST_ENV`) unless `define` sets it. A defined `HOME` ran
|
|
2348
|
+
* with the worker's own, saved under a key that folded the defined one
|
|
2349
|
+
* (2026-10-24).
|
|
2350
|
+
*/
|
|
2351
|
+
function defined(
|
|
2352
|
+
env: NodeJS.ProcessEnv,
|
|
2353
|
+
define: Readonly<Record<string, string>>,
|
|
2354
|
+
): Record<string, string> {
|
|
2355
|
+
const out: Record<string, string> = {}
|
|
2356
|
+
for (const [k, v] of Object.entries(env))
|
|
2357
|
+
if (v !== undefined && (!HOST_ENV.includes(k) || k in define)) out[k] = v
|
|
2358
|
+
return out
|
|
2359
|
+
}
|