@vzn/vx-dte 0.0.0 → 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/src/worker.ts ADDED
@@ -0,0 +1,1038 @@
1
+ // `vx dte worker`: one CI job's share of a run. It joins the run's relay
2
+ // with what its machine has, runs what the orchestrator sends in its own
3
+ // checkout, each at once: the orchestrator sends what fits the load each
4
+ // frame of this worker reports. A task's dependencies' files come from the workspace's
5
+ // cache by the keys its order names, and its own files and output go
6
+ // there under its key; the relay carries only orders and status.
7
+
8
+ import { readFileSync, realpathSync } from 'node:fs'
9
+ import { lstat, rm } from 'node:fs/promises'
10
+ import os, { constants } from 'node:os'
11
+ import path from 'node:path'
12
+ import {
13
+ answerRuntime,
14
+ BoundedCapture,
15
+ hostPlatform,
16
+ localFloor,
17
+ machineMemoryBytes,
18
+ ui,
19
+ machineParallelism,
20
+ packageManagerPath,
21
+ sampleTrees,
22
+ sandboxRequestFor,
23
+ sweepPlaceholders,
24
+ isUserError,
25
+ UserError,
26
+ type CacheFacts,
27
+ type CapturedChunk,
28
+ type ExecuteRequest,
29
+ type LocalFloor,
30
+ type TaskArtifacts,
31
+ type TreeUsage,
32
+ } from '@vzn/vx'
33
+ import { childrenCpuMs, describeCpu, readPs, topBetween } from './cpu-account.js'
34
+ import {
35
+ bytesOf,
36
+ HELD_EVERY,
37
+ HOST_ENV,
38
+ PART_LISTS,
39
+ type DepOrder,
40
+ type Load,
41
+ type WorkerInfo,
42
+ type RunOrder,
43
+ type ToOrchestrator,
44
+ type ToWorker,
45
+ type WiredResult,
46
+ } from './messages.js'
47
+ import { leftUnder, names, outputFiles } from './outputs.js'
48
+ import { snapshot } from './tree.js'
49
+ import { sealWorker, type Proof } from './seal.js'
50
+ import { join, RelayError, unsent as unsentBy } from './wire.js'
51
+
52
+ export async function runWorker(opts: {
53
+ relay: string
54
+ run: string
55
+ /** `WorkerSpawn.id`, when the run started it. */
56
+ id?: string
57
+ root: string
58
+ token: () => Promise<string | undefined>
59
+ /** `dte({ headers })`, on every dial. */
60
+ headers?: Readonly<Record<string, string>>
61
+ /** What admits it to the orchestrator's session: the run's key, or its CI identity. */
62
+ proof: Proof
63
+ /** The workspace's cache read under the facts it is handed (`CommandContext.artifactsFor`). */
64
+ artifacts: (facts: () => CacheFacts | undefined) => TaskArtifacts
65
+ }): Promise<number> {
66
+ // The tracked files as they are, as the run snapshots its own: HEAD's
67
+ // tree let in a checkout whose prepare rewrote a tracked file, and the
68
+ // task built from it was saved under the clean key (2026-10-25).
69
+ const base = await snapshot(opts.root)
70
+ const tree = base.tree
71
+ // Never an output, as core never cleans one: the checkout is the run's
72
+ // commit, so this is the run's tracked set.
73
+ const tracked = trackedFiles(opts.root)
74
+ const cores = machineParallelism()
75
+ const memory = machineMemoryBytes()
76
+ const info: WorkerInfo = {
77
+ v: 1,
78
+ ...(opts.id === undefined ? {} : { id: opts.id }),
79
+ cores,
80
+ memory,
81
+ platform: hostPlatform(),
82
+ tree,
83
+ booted: Math.round(performance.timeOrigin),
84
+ }
85
+ let me
86
+ try {
87
+ me = await sealWorker(
88
+ (signed) => join(opts.relay, opts.run, 'worker', signed, opts.token, opts.headers),
89
+ opts.run,
90
+ opts.proof,
91
+ { ...info },
92
+ )
93
+ } catch (err) {
94
+ if (err instanceof RelayError && err.status === 410) {
95
+ // The run ended before this worker came up: nothing is left for it,
96
+ // and its CI job is no failure. One started by hand may have come
97
+ // before its run, so it says so: an exit with no word left the run
98
+ // waiting on nobody (2026-10-25).
99
+ if (opts.id === undefined) {
100
+ ui.print(
101
+ 'err',
102
+ ui.status(
103
+ 'warn',
104
+ 'vx dte worker',
105
+ `the relay has no live run ${opts.run}: it ended, or has not started (start the run first, then its workers)`,
106
+ ),
107
+ )
108
+ }
109
+ return 0
110
+ }
111
+ throw err
112
+ }
113
+ // Read as the run reads: its refs, and what its remotes lent.
114
+ let facts: CacheFacts | undefined
115
+ const artifacts = opts.artifacts(() => facts)
116
+ const running = new Map<string, AbortController>()
117
+ /** Each order's deps sent ahead of its batch, until the batch comes. */
118
+ const ahead = new Map<string, DepOrder[]>()
119
+ const work = new Set<Promise<void>>()
120
+ let root = opts.root
121
+ let realRoot = opts.root
122
+ const myReal = realpathSync(opts.root)
123
+ let floor: LocalFloor | undefined
124
+ const load = loadMeter()
125
+ const started = {
126
+ at: performance.now(),
127
+ self: process.cpuUsage(),
128
+ children: childrenCpuMs(),
129
+ ps: readPs(),
130
+ }
131
+ let tasksCpuMs = 0
132
+ const trees = treeMeter()
133
+ const frame = (): Load => ({ ...load.now(), tasks: trees.now() })
134
+ /**
135
+ * Entries whose files are here, restored or run here, by key: tasks
136
+ * started together wait on one restore, never run before it.
137
+ */
138
+ const present = new Map<string, Promise<void>>()
139
+ /** Of `present`, which files each holds: a task that clears one makes it absent. */
140
+ const holds = new Map<string, (file: string) => boolean>()
141
+ // Each task runs in a process group of its own, which a Ctrl-C (local()
142
+ // starts this worker in the terminal's group) or a CI job's stop never
143
+ // reaches: a worker that died of the signal left its tasks running. So
144
+ // it stops them, as `vx run` stops its own, and leaves. Once: a second
145
+ // signal is the default's, the user saying "now".
146
+ const quit = new AbortController()
147
+ const onSignal = (signal: 'SIGINT' | 'SIGTERM') => (): void => {
148
+ quit.abort(signal)
149
+ for (const c of running.values()) c.abort(signal)
150
+ }
151
+ const onInt = onSignal('SIGINT')
152
+ const onTerm = onSignal('SIGTERM')
153
+ process.once('SIGINT', onInt)
154
+ process.once('SIGTERM', onTerm)
155
+ const quitCode = (): number => 128 + constants.signals[quit.signal.reason as 'SIGINT' | 'SIGTERM']
156
+
157
+ /** Saves the task's files (none when `bare`) and output under `key` and says so. */
158
+ const reply = async (
159
+ m: RunOrder,
160
+ key: string,
161
+ others: ReadonlySet<string> | undefined,
162
+ res: WiredResult & { output: CapturedChunk[] },
163
+ bare = false,
164
+ ): Promise<void> => {
165
+ // Stopped by a signal: its end would fail the task in the run, which
166
+ // runs it again elsewhere once this worker leaves.
167
+ if (quit.signal.aborted) return
168
+ const { output, ...result } = res
169
+ // Forced: the key keeps an entry it holds, so the run's machines restore
170
+ // the fresh bytes under the run key.
171
+ const keys = !bare && m.fresh === true && key === m.key ? [key, m.runKey] : [key]
172
+ try {
173
+ for (const k of keys)
174
+ await artifacts.save(
175
+ k,
176
+ {
177
+ id: m.task,
178
+ projectDir: path.join(opts.root, m.project),
179
+ command: m.command,
180
+ outputs: bare ? { files: [], workspaceFiles: [] } : m.outputs,
181
+ nestedProjectDirs: m.nested.map((n) => path.join(opts.root, n)),
182
+ ...(others === undefined || others.size === 0 ? {} : { others }),
183
+ },
184
+ {
185
+ output,
186
+ durationMs: res.durationMs,
187
+ ...(res.cpuMs === undefined ? {} : { cpuMs: res.cpuMs }),
188
+ ...(res.peakRssBytes === undefined ? {} : { peakRssBytes: res.peakRssBytes }),
189
+ env: m.env,
190
+ ...(m.secret === undefined ? {} : { secret: m.secret }),
191
+ // The run's own key is this run's alone; `key` as the probe found it.
192
+ ...(k === m.runKey || (k === m.key && m.absent === true) ? { absent: true } : {}),
193
+ },
194
+ )
195
+ } catch (err) {
196
+ // Its outputs refused (a link out of its project): the task fails, as
197
+ // core fails it, not this worker. Taken as a fault, it failed every
198
+ // worker it reached and the run with them (2026-10-24).
199
+ if (bare || !isUserError(err)) throw err
200
+ return reply(
201
+ m,
202
+ m.runKey,
203
+ undefined,
204
+ { ...res, exitCode: 1, output: [...output, { text: `${err.message}\n`, err: true }] },
205
+ true,
206
+ )
207
+ }
208
+ // No remote took it: the orchestrator reads the remotes alone, so its
209
+ // dependants come here and its output goes with the `end` (2026-10-25).
210
+ const saved = keys.at(-1)!
211
+ const wired: WiredResult =
212
+ (await artifacts.uploaded?.(saved)) === false
213
+ ? { ...result, unshared: true, output: pieces(output) }
214
+ : result
215
+ if (result.exitCode === 0)
216
+ for (const k of keys) {
217
+ present.set(k, Promise.resolve())
218
+ holds.set(k, names(m.project, m.outputs))
219
+ }
220
+ const end: ToOrchestrator = {
221
+ type: 'end',
222
+ task: m.task,
223
+ key: saved,
224
+ result: wired,
225
+ load: frame(),
226
+ }
227
+ for (const part of parts(end)) await me.send('orchestrator', part)
228
+ }
229
+
230
+ // A message the relay would not take is said, each kind once (2026-10-23):
231
+ // a lost `ran` or `held` left the run waiting on this worker for nothing.
232
+ const said = new Set<string>()
233
+ const unsent = (what: string): ((err: unknown) => void) =>
234
+ unsentBy(
235
+ (m) => {
236
+ if (said.has(what)) return
237
+ said.add(what)
238
+ ui.print('err', ui.status('warn', 'vx dte worker', m))
239
+ },
240
+ what,
241
+ 'the orchestrator',
242
+ )
243
+ /** Runs a task the orchestrator sent: it fits what this worker has free. */
244
+ const start = (m: RunOrder): void => {
245
+ let ran = false
246
+ const done = (): void => {
247
+ if (ran) return
248
+ ran = true
249
+ void me
250
+ .send('orchestrator', { type: 'ran', task: m.task, load: frame() })
251
+ .catch(unsent('ran'))
252
+ }
253
+ const p = runTask(m, done)
254
+ .catch(async (err: unknown) => {
255
+ // The task's own error (a sandbox this machine cannot host, an
256
+ // `exec.sandbox` it refuses) fails it, as core fails it. Taken as a
257
+ // fault, each worker it reached left the run, and unsandboxed tasks
258
+ // failed with no worker left (docker(), 2026-10-25).
259
+ if (isUserError(err)) {
260
+ ui.print('err', ui.status('error', m.task, err.message))
261
+ const res = { exitCode: 1, durationMs: 0, violations: [] }
262
+ const output = [{ text: `${err.message}\n`, err: true }]
263
+ const told = await reply(m, m.runKey, undefined, { ...res, output }, true).then(
264
+ () => true,
265
+ (e: unknown) => ((err = e), false),
266
+ )
267
+ if (told) return
268
+ }
269
+ const reason = err instanceof Error ? err.message : String(err)
270
+ ui.print('err', ui.status('error', m.task, `could not run: ${reason}`))
271
+ await me
272
+ .send('orchestrator', { type: 'fault', task: m.task, reason })
273
+ .catch(unsent('fault'))
274
+ })
275
+ .finally(() => work.delete(p))
276
+ work.add(p)
277
+ }
278
+
279
+ // Each probe once per worker, as a run answers it once.
280
+ const probes = new Map<string, Promise<string>>()
281
+ /** The first probe this machine answers otherwise than the orchestrator did. */
282
+ const runtimeDiffers = async (m: RunOrder): Promise<string | undefined> => {
283
+ for (const r of m.runtime ?? []) {
284
+ const dir = r.workspace === true ? '' : m.project
285
+ const k = `${dir}\0${r.command}`
286
+ let p = probes.get(k)
287
+ if (p === undefined) {
288
+ p = answerRuntime(r.command, path.join(opts.root, dir), opts.root, r.workspace === true)
289
+ probes.set(k, p)
290
+ }
291
+ // Its checkout root read as the run's: a path under it names the same
292
+ // file there, as the task's env does. Compared raw, a probe of a
293
+ // workspace tool's path failed every task on a worker checked out
294
+ // elsewhere (2026-10-25).
295
+ const mine = swapRoots(await p, [[opts.root, root]])
296
+ if (mine !== r.output)
297
+ return `\`${r.command}\` answers ${JSON.stringify(mine)} on this worker, ${JSON.stringify(r.output)} on the run's machine, whose answer its key holds`
298
+ }
299
+ return undefined
300
+ }
301
+
302
+ const runTask = async (m: RunOrder, free: () => void): Promise<void> => {
303
+ const stop = new AbortController()
304
+ running.set(m.task, stop)
305
+ // Not awaited: the task does not wait on the relay to say it began.
306
+ void me
307
+ .send('orchestrator', { type: 'start', task: m.task, load: frame() })
308
+ .catch(unsent('start'))
309
+ try {
310
+ const differs = await runtimeDiffers(m)
311
+ if (differs !== undefined) {
312
+ ui.print('err', ui.status('error', m.task, differs))
313
+ await reply(m, m.runKey, undefined, {
314
+ exitCode: 1,
315
+ durationMs: 0,
316
+ output: [{ text: `${differs}\n`, err: true }],
317
+ violations: [],
318
+ })
319
+ return
320
+ }
321
+ for (const d of m.deps) {
322
+ let got = present.get(d.key)
323
+ if (got === undefined) {
324
+ got = artifacts
325
+ .restore(d.key, {
326
+ id: d.task,
327
+ projectDir: path.join(opts.root, d.project),
328
+ command: '',
329
+ outputs: d.outputs,
330
+ })
331
+ .then((log) => {
332
+ if (log === null)
333
+ throw new UserError(`${d.task}'s entry ${d.key} is in no cache this worker reads`)
334
+ })
335
+ present.set(d.key, got)
336
+ holds.set(d.key, names(d.project, d.outputs))
337
+ // quiet: awaited below, where its error is the task's fault
338
+ got.catch(() => present.delete(d.key))
339
+ }
340
+ try {
341
+ await got
342
+ } catch (err) {
343
+ // No worker reads it either: the task fails, naming it. Taken as a
344
+ // fault, each worker it reached was dropped and the run waited on
345
+ // a worker that never came (2026-10-24).
346
+ if (!isUserError(err)) throw err
347
+ ui.print('err', ui.status('error', m.task, err.message))
348
+ const res = { exitCode: 1, durationMs: 0, violations: [], unread: [d.key] }
349
+ await reply(
350
+ m,
351
+ m.runKey,
352
+ undefined,
353
+ { ...res, output: [{ text: `${err.message}\n`, err: true }] },
354
+ true,
355
+ )
356
+ return
357
+ }
358
+ }
359
+ // An earlier task's files under these outputs would ship as this one's.
360
+ // A tracked file or a nested project's is none of them: cleared, a
361
+ // committed file under `gen/**` was gone for the task's dependants
362
+ // (2026-10-24). Nor is a dependency's: an additive task (outputs over
363
+ // its upstream's tree) found the files it builds on gone.
364
+ const upstream = m.deps.map((d) => names(d.project, d.outputs))
365
+ const under = await outputFiles(opts.root, m.project, m.outputs)
366
+ const { cleared, held: upstreams } = leftUnder(under, tracked, m.nested, upstream)
367
+ // An upstream's file it leaves as it found it is not its entry's: one
368
+ // saved there replayed the upstream's old bytes over its new ones on
369
+ // a hit that did not fold the upstream's key (2026-10-24).
370
+ const found = await stamps(upstreams.map((f) => path.join(opts.root, f)))
371
+ for (const [key, held] of holds)
372
+ if (cleared.some(held)) {
373
+ present.delete(key)
374
+ holds.delete(key)
375
+ }
376
+ for (const f of cleared) await rm(path.join(opts.root, f), { force: true })
377
+ // Built here: the walls, real paths and grants are this machine's.
378
+ const sb =
379
+ m.sandbox === undefined
380
+ ? undefined
381
+ : await sandboxRequestFor(
382
+ { projectDir: path.join(opts.root, m.project) },
383
+ m.sandbox.config,
384
+ opts.root,
385
+ m.sandbox.keyed === undefined
386
+ ? undefined
387
+ : new Set(m.sandbox.keyed.map((k) => path.join(opts.root, k))),
388
+ m.nested.map((n) => path.join(opts.root, n)),
389
+ )
390
+ const output = new BoundedCapture()
391
+ const req: ExecuteRequest = {
392
+ taskId: m.task,
393
+ workspaceRoot: opts.root,
394
+ command: m.command,
395
+ forwardArgs: [],
396
+ cwd: path.join(opts.root, m.cwd),
397
+ env: localEnv(m.env, root, opts.root),
398
+ envDefine: {},
399
+ // Both streams as one capture, in the order written, as core keeps a
400
+ // run's: kept apart, a hit replayed every stdout line before the
401
+ // stderr between them (2026-10-25).
402
+ capture: { stdout: false, stderr: false },
403
+ onStdout: (t) => output.push(t),
404
+ onStderr: (t) => output.push(t, true),
405
+ signal: stop.signal,
406
+ onSpawn: (pid) => trees.watch(m.task, pid),
407
+ outputs: m.outputs,
408
+ ...(m.timeoutMs === undefined ? {} : { timeoutMs: m.timeoutMs }),
409
+ ...(sb === undefined ? {} : { sandbox: sb.sandbox }),
410
+ ...(m.servers === undefined
411
+ ? {}
412
+ : {
413
+ servers: m.servers.map((s) => ({
414
+ taskId: s.task,
415
+ command: s.command,
416
+ cwd: path.join(opts.root, s.cwd),
417
+ env: localEnv(s.env, root, opts.root),
418
+ ...(s.readyWhen === undefined ? {} : { readyWhen: s.readyWhen }),
419
+ ...(s.timeoutMs === undefined ? {} : { timeoutMs: s.timeoutMs }),
420
+ ...(s.sandbox === undefined
421
+ ? {}
422
+ : {
423
+ sandbox: {
424
+ config: s.sandbox.config,
425
+ nested: s.sandbox.nested.map((n) => path.join(opts.root, n)),
426
+ },
427
+ }),
428
+ })),
429
+ }),
430
+ }
431
+ floor ??= localFloor()
432
+ ui.print('out', ui.status('info', m.task, ui.dim('running')))
433
+ const crowd = load.mark()
434
+ const {
435
+ outputs: _,
436
+ stdout: _o,
437
+ stderr: _e,
438
+ ...res
439
+ } = await floor.execute(req).finally(() => trees.drop(m.task))
440
+ tasksCpuMs += res.cpuMs ?? 0
441
+ const cpuShare = load.shareSince(
442
+ crowd,
443
+ res.cpuMs === undefined || !(res.durationMs > 0) ? undefined : res.cpuMs / res.durationMs,
444
+ )
445
+ // Its command is done: saving its outputs to the cache is the
446
+ // network's time, and its cores are the next task's (main,
447
+ // 2026-10-23: workers booked 3.7 cores and used 2.4).
448
+ free()
449
+ const took = ui.dim(`${Math.round(res.durationMs)}ms`)
450
+ ui.print(
451
+ 'out',
452
+ res.exitCode === 0
453
+ ? ui.status('ok', m.task, took)
454
+ : ui.status('error', m.task, [`exit ${res.exitCode} `, took]),
455
+ )
456
+ // A bind the task never wrote is not its output. By size alone, an
457
+ // empty stamp the task wrote was taken too and saved as no file
458
+ // (2026-10-24).
459
+ if (sb !== undefined) await sweepPlaceholders(sb.placeholders)
460
+ // Its violations name the orchestrator's paths. As JSON first: the swap
461
+ // round-trips them through JSON, which leaves a Date a string.
462
+ const back = swapRoots(
463
+ res.violations.map((v) => ({ ...v, timestamp: v.timestamp.toISOString() })),
464
+ [
465
+ [myReal, realRoot],
466
+ [opts.root, root],
467
+ ],
468
+ )
469
+ // Only a success the sandbox found nothing wrong with is the task's
470
+ // entry; anything else is kept where no lookup finds it. A 0 past the
471
+ // timeout (a trapped SIGTERM) failed, and saved under the key the
472
+ // next run hit it green (2026-10-24). A process it left running may
473
+ // still write its outputs: core saves no entry, nor does this. Nor for
474
+ // one the run cancelled: a trapped SIGTERM's 0 was a stopped run's
475
+ // half-done task, and the next run hit it (2026-10-24).
476
+ // A tracked file it changed under its project (a formatter's input)
477
+ // is one the orchestrator's disk never shows: its key read the file
478
+ // as checked out, and the entry built from the rewrite was the next
479
+ // run's hit (2026-10-24). Core withholds its own save if the key read
480
+ // it; this entry is withheld for any. Nor for an additive task that
481
+ // removed an upstream's file: a hit left the file in place, and core
482
+ // saves none once this machine removes it too (2026-10-24).
483
+ const changed = await changedTracked(opts.root, base.commit, m, names(m.project, m.outputs))
484
+ const now = await stamps([...found.keys()])
485
+ const others = unchanged(found, now)
486
+ const removed = [...found.keys()]
487
+ .filter((f) => !now.has(f))
488
+ .map((f) => path.relative(opts.root, f))
489
+ const key =
490
+ res.exitCode === 0 &&
491
+ res.timedOut !== true &&
492
+ res.leftRunning !== true &&
493
+ !stop.signal.aborted &&
494
+ res.violations.length === 0 &&
495
+ changed.length === 0 &&
496
+ removed.length === 0 &&
497
+ m.key !== undefined
498
+ ? m.key
499
+ : m.runKey
500
+ await reply(m, key, others, {
501
+ ...res,
502
+ output: output.chunks(),
503
+ violations: back,
504
+ ...(cpuShare === undefined ? {} : { cpuShare }),
505
+ ...(changed.length === 0 ? {} : { changed }),
506
+ ...(removed.length === 0 ? {} : { removed }),
507
+ })
508
+ } finally {
509
+ running.delete(m.task)
510
+ }
511
+ }
512
+
513
+ // What it holds, every `HELD_EVERY` heartbeats: the orchestrator's lease on each task.
514
+ const held = setInterval(() => {
515
+ if (running.size === 0) return
516
+ void me
517
+ .send('orchestrator', { type: 'held', running: [...running.keys()], load: frame() })
518
+ .catch(unsent('held'))
519
+ }, HELD_EVERY * me.heartbeatMs)
520
+ try {
521
+ for (;;) {
522
+ let messages
523
+ try {
524
+ messages = await me.poll(quit.signal)
525
+ } catch (err) {
526
+ if (quit.signal.aborted) return quitCode()
527
+ if (err instanceof RelayError && err.status === 410) return 0
528
+ // The relay lost it (silent too long, or restarted): the run gave its
529
+ // tasks to others, so this job has nothing left and is no failure.
530
+ if (err instanceof RelayError && err.status === 404) {
531
+ ui.print(
532
+ 'err',
533
+ ui.status('warn', 'vx dte worker', 'the relay no longer knows this worker'),
534
+ )
535
+ return 0
536
+ }
537
+ throw err
538
+ }
539
+ if (quit.signal.aborted) return quitCode()
540
+ for (const { from, body } of messages) {
541
+ if (from === 'relay') {
542
+ if ((body as { type?: string }).type === 'closed') return 0
543
+ continue
544
+ }
545
+ const m = body as ToWorker
546
+ if (m.type === 'refused') {
547
+ ui.print('err', ui.status('error', 'vx dte worker', `refused: ${m.reason}`))
548
+ return 1
549
+ }
550
+ if (m.type === 'exit') return 0
551
+ if (m.type === 'cache') facts = m.cache
552
+ else if (m.type === 'welcome') {
553
+ facts = m.cache
554
+ ui.print(
555
+ 'out',
556
+ ui.report('vx dte worker', [
557
+ ['worker', ui.id(m.name)],
558
+ ['run', opts.run],
559
+ ['relay', opts.relay],
560
+ ['machine', `${cores} cores · ${gb(memory)}`],
561
+ ]),
562
+ )
563
+ } else if (m.type === 'deps') {
564
+ ahead.set(m.task, [...(ahead.get(m.task) ?? []), ...m.deps])
565
+ } else if (m.type === 'cancel') {
566
+ running.get(m.task)?.abort()
567
+ } else if (m.type === 'batch') {
568
+ // Every batch of a run names the same orchestrator root.
569
+ root = m.root
570
+ realRoot = m.realRoot
571
+ if (m.sandboxes !== undefined) {
572
+ if (floor === undefined) floor = localFloor(m.sandboxes)
573
+ else await floor.rearm(m.sandboxes)
574
+ }
575
+ for (const t of m.tasks) {
576
+ // One it runs already, sent again (its lease ended while the
577
+ // relay was away): it is not run twice.
578
+ if (running.has(t.task)) continue
579
+ const deps = ahead.get(t.task)
580
+ ahead.delete(t.task)
581
+ start(deps === undefined ? t : { ...t, deps: [...deps, ...t.deps] })
582
+ }
583
+ }
584
+ }
585
+ }
586
+ } catch (err) {
587
+ // The run closed under a poll or an answer: nothing is left for this worker.
588
+ if (err instanceof RelayError && err.status === 410) return 0
589
+ throw err
590
+ } finally {
591
+ process.off('SIGINT', onInt)
592
+ process.off('SIGTERM', onTerm)
593
+ clearInterval(held)
594
+ trees.stop()
595
+ for (const c of running.values()) c.abort()
596
+ await Promise.all(work)
597
+ const onlineMs = performance.now() - started.at
598
+ const self = process.cpuUsage(started.self)
599
+ const children = childrenCpuMs()
600
+ const ps = readPs()
601
+ ui.print(
602
+ 'out',
603
+ ui.status(
604
+ 'info',
605
+ 'vx dte worker',
606
+ describeCpu({
607
+ onlineMs,
608
+ machineMs: (load.now().avg ?? 0) * onlineMs,
609
+ tasksMs: tasksCpuMs,
610
+ selfMs: (self.user + self.system) / 1000,
611
+ ...(children === undefined || started.children === undefined
612
+ ? {}
613
+ : { childrenMs: Math.max(0, children - started.children - tasksCpuMs) }),
614
+ top:
615
+ started.ps === undefined || ps === undefined
616
+ ? []
617
+ : topBetween(started.ps.table, ps, process.pid),
618
+ }),
619
+ ),
620
+ )
621
+ load.stop()
622
+ await floor?.close()
623
+ await me.leave()
624
+ }
625
+ }
626
+
627
+ /** The looks a task's share is read over, and the CPUs' busy and total time then. */
628
+ interface Mark {
629
+ sum: number
630
+ n: number
631
+ times: [number, number]
632
+ }
633
+
634
+ /** How often the run queue is read: a test shard's spawns come and go within a second. */
635
+ const CROWD_EVERY_MS = 200
636
+ /** The share of the CPUs' time busy at which a Mac may be crowded. */
637
+ const FULL = 0.9
638
+
639
+ function cpuTimes(): [number, number] {
640
+ let idle = 0
641
+ let total = 0
642
+ for (const c of os.cpus()) {
643
+ const t = c.times
644
+ idle += t.idle
645
+ total += t.user + t.nice + t.sys + t.irq + t.idle
646
+ }
647
+ return [total - idle, total]
648
+ }
649
+
650
+ /**
651
+ * The share of a core each runnable thread gets now: the machine's cores
652
+ * over the threads Linux has runnable (`procs_running`, less the one
653
+ * reading it), at most 1. Undefined where there is no /proc/stat.
654
+ */
655
+ export function crowdShare(stat: string, cores: number): number | undefined {
656
+ const m = /^procs_running (\d+)$/m.exec(stat)
657
+ if (m === null) return undefined
658
+ return Math.min(1, cores / Math.max(1, Number(m[1]) - 1))
659
+ }
660
+
661
+ /**
662
+ * This machine's load, sampled each second: the cores busy over the last
663
+ * one, from the CPUs' times, and the memory in use. Also how crowded its
664
+ * CPU is, read every `CROWD_EVERY_MS`: a task on a crowded machine uses
665
+ * only its share, and the run learns its need from what it used. With no
666
+ * /proc/stat (macOS), the load average says it: six test shards on a
667
+ * 3-core Mac reported no share, so the run learned their crowded use as
668
+ * their need and packed them as tight again: 66-78 s each (main 1e19c00,
669
+ * 2026-10-23). Lagging a minute, it fits the long tasks a
670
+ * crowd stretches. A load average of 0 (Windows) is none. It counts
671
+ * more than the threads that wait for a core: a GitHub macOS runner
672
+ * read 49 with two test shards on 3 cores and a core idle, and the run
673
+ * learned each shard needed the whole machine (main 8fa3c98). So a
674
+ * Mac whose cores were not all busy since the last look is not crowded.
675
+ */
676
+ export function loadMeter(
677
+ read: () => string | undefined = procStat,
678
+ average: () => number = () => os.loadavg()[0] ?? 0,
679
+ /** The CPUs' busy and total time so far (`os.cpus()`). */
680
+ times: () => [number, number] = cpuTimes,
681
+ ): {
682
+ now: () => Load
683
+ mark: () => Mark
684
+ shareSince: (from: Mark, used?: number) => number | undefined
685
+ stop: () => void
686
+ } {
687
+ let last = times()
688
+ const first = last
689
+ let cpu = 0
690
+ const timer = setInterval(() => {
691
+ const next = times()
692
+ const total = next[1] - last[1]
693
+ if (total > 0) cpu = ((next[0] - last[0]) / total) * os.cpus().length
694
+ last = next
695
+ }, 1_000)
696
+ timer.unref?.()
697
+ const cores = os.cpus().length
698
+ const crowd = { sum: 0, n: 0 }
699
+ let looked = last
700
+ const look = (): void => {
701
+ const stat = read()
702
+ const avg = stat === undefined ? average() : 0
703
+ let full = true
704
+ if (stat === undefined && avg > 0) {
705
+ const now = times()
706
+ full = now[1] <= looked[1] || (now[0] - looked[0]) / (now[1] - looked[1]) >= FULL
707
+ looked = now
708
+ }
709
+ const share =
710
+ stat !== undefined
711
+ ? crowdShare(stat, cores)
712
+ : avg > 0
713
+ ? full
714
+ ? Math.min(1, cores / avg)
715
+ : 1
716
+ : undefined
717
+ if (share === undefined) return clearInterval(crowded)
718
+ crowd.sum += share
719
+ crowd.n++
720
+ }
721
+ const crowded = setInterval(look, CROWD_EVERY_MS)
722
+ crowded.unref?.()
723
+ return {
724
+ now: () => {
725
+ const t = times()
726
+ const total = t[1] - first[1]
727
+ return {
728
+ cpu: Math.round(cpu * 100) / 100,
729
+ memory: os.totalmem() - available(),
730
+ ...(total > 0 ? { avg: Math.round(((t[0] - first[0]) / total) * cores * 100) / 100 } : {}),
731
+ }
732
+ },
733
+ mark: () => ({ ...crowd, times: times() }),
734
+ // Its mean share over the looks since `from`; none (a task shorter
735
+ // than a look, or no /proc) is no correction. A task that used `used`
736
+ // cores beside cores left free wanted at most both: six test shards
737
+ // on a 3-core Mac used 2.3 cores, read a share of 0.22 from its load
738
+ // average, and the next run booked each 2.2 cores (main 3917881).
739
+ shareSince: (from, used) => {
740
+ if (crowd.n <= from.n) return undefined
741
+ let share = (crowd.sum - from.sum) / (crowd.n - from.n)
742
+ const now = times()
743
+ const total = now[1] - from.times[1]
744
+ if (used !== undefined && used > 0 && total > 0) {
745
+ const free = Math.max(0, 1 - (now[0] - from.times[0]) / total) * cores
746
+ share = Math.max(share, Math.min(1, used / (used + free)))
747
+ }
748
+ return Math.round(share * 1000) / 1000
749
+ },
750
+ stop: () => {
751
+ clearInterval(timer)
752
+ clearInterval(crowded)
753
+ },
754
+ }
755
+ }
756
+
757
+ /** How often each running task's process tree is read: core's own `task.sample` cadence. */
758
+ const TREE_EVERY_MS = 1_000
759
+
760
+ /**
761
+ * The cores each running task's whole process tree uses, read every
762
+ * second, summed: beside the machine's busy cores, the rest is no task's
763
+ * (the worker's own restores and saves, the OS). A Mac whose tasks'
764
+ * exits said 1.8 cores ran them at half their share (main fd9afe0,
765
+ * 2026-10-24), and nothing said where the rest went.
766
+ */
767
+ export function treeMeter(
768
+ sample: (roots: readonly number[]) => Promise<ReadonlyMap<number, TreeUsage>> = sampleTrees,
769
+ every = TREE_EVERY_MS,
770
+ ): {
771
+ watch: (task: string, pid: number) => void
772
+ drop: (task: string) => void
773
+ now: () => number
774
+ stop: () => void
775
+ } {
776
+ const watched = new Map<string, { pid: number; at: number; cpuMs: number; cores: number }>()
777
+ let timer: ReturnType<typeof setInterval> | undefined
778
+ let looking = false
779
+ const look = async (): Promise<void> => {
780
+ if (looking) return
781
+ looking = true
782
+ try {
783
+ const entries = [...watched]
784
+ const usage = await sample(entries.map(([, w]) => w.pid))
785
+ const at = performance.now()
786
+ for (const [task, w] of entries) {
787
+ const u = usage.get(w.pid)
788
+ if (u === undefined || watched.get(task) !== w) continue
789
+ // A child that exited takes its CPU out of the live sum.
790
+ if (at > w.at) w.cores = Math.max(0, (u.cpuMs - w.cpuMs) / (at - w.at))
791
+ w.at = at
792
+ w.cpuMs = u.cpuMs
793
+ }
794
+ } catch {
795
+ // quiet: a failed look is a missing point; the last one stands
796
+ } finally {
797
+ looking = false
798
+ }
799
+ }
800
+ const stop = (): void => {
801
+ if (timer !== undefined) clearInterval(timer)
802
+ timer = undefined
803
+ }
804
+ return {
805
+ watch: (task, pid) => {
806
+ watched.set(task, { pid, at: performance.now(), cpuMs: 0, cores: 0 })
807
+ if (timer === undefined) {
808
+ timer = setInterval(() => void look(), every)
809
+ timer.unref?.()
810
+ }
811
+ },
812
+ drop: (task) => {
813
+ watched.delete(task)
814
+ if (watched.size === 0) stop()
815
+ },
816
+ now: () => {
817
+ let cores = 0
818
+ for (const w of watched.values()) cores += w.cores
819
+ return Math.round(cores * 100) / 100
820
+ },
821
+ stop,
822
+ }
823
+ }
824
+
825
+ function procStat(): string | undefined {
826
+ try {
827
+ return readFileSync('/proc/stat', 'utf8')
828
+ } catch {
829
+ // quiet: not Linux: no run queue to read, so no correction
830
+ return undefined
831
+ }
832
+ }
833
+
834
+ /**
835
+ * The tracked files under `m`'s project that differ from the checkout,
836
+ * workspace-relative: its outputs and nested projects' files are not its
837
+ * project's inputs.
838
+ */
839
+ type Stamp = { size: number; mtimeMs: number; ino: number; ctimeMs: number }
840
+
841
+ /** Each file's stat, by absolute path. */
842
+ async function stamps(files: readonly string[]): Promise<Map<string, Stamp>> {
843
+ const out = new Map<string, Stamp>()
844
+ for (const f of files) {
845
+ // quiet: a file gone is one with no stamp
846
+ const st = await lstat(f).catch(() => undefined)
847
+ if (st !== undefined)
848
+ out.set(f, { size: st.size, mtimeMs: st.mtimeMs, ino: st.ino, ctimeMs: st.ctimeMs })
849
+ }
850
+ return out
851
+ }
852
+
853
+ /** Of `found`, the files still as they were. */
854
+ function unchanged(
855
+ found: ReadonlyMap<string, Stamp>,
856
+ now: ReadonlyMap<string, Stamp>,
857
+ ): Set<string> {
858
+ const out = new Set<string>()
859
+ for (const [f, was] of found) {
860
+ const is = now.get(f)
861
+ if (
862
+ is !== undefined &&
863
+ is.size === was.size &&
864
+ is.mtimeMs === was.mtimeMs &&
865
+ is.ino === was.ino &&
866
+ is.ctimeMs === was.ctimeMs
867
+ )
868
+ out.add(f)
869
+ }
870
+ return out
871
+ }
872
+
873
+ /** Tracked files under the task's project it changed, workspace-relative, its outputs and nested projects' aside. */
874
+ export async function changedTracked(
875
+ root: string,
876
+ base: string,
877
+ m: RunOrder,
878
+ output: (file: string) => boolean,
879
+ ): Promise<string[]> {
880
+ const p = Bun.spawn(
881
+ [
882
+ 'git',
883
+ 'diff',
884
+ '--name-only',
885
+ '-z',
886
+ // Paths as the run names them: repo-relative, a workspace in a
887
+ // subdirectory matched no keyed file, and core saved the rewrite
888
+ // under its key (2026-10-25).
889
+ '--relative',
890
+ '--no-renames',
891
+ base,
892
+ '--',
893
+ // Literal: a project at `p*` named its sibling `pa`'s files.
894
+ m.project === '' ? '.' : `:(literal)${m.project}`,
895
+ ],
896
+ {
897
+ cwd: root,
898
+ stdin: 'ignore',
899
+ stdout: 'pipe',
900
+ stderr: 'ignore',
901
+ },
902
+ )
903
+ const [out, exit] = await Promise.all([new Response(p.stdout).text(), p.exited])
904
+ // A git that could not say is no proof the task changed nothing: the
905
+ // project itself is named, and its entry is withheld.
906
+ if (exit !== 0) return [m.project === '' ? '.' : m.project]
907
+ return out
908
+ .split('\0')
909
+ .filter((f) => f !== '' && !output(f) && !m.nested.some((n) => f.startsWith(`${n}/`)))
910
+ }
911
+
912
+ /** The checkout's tracked files, relative to `root`; none outside git. */
913
+ export function trackedFiles(root: string): Set<string> {
914
+ const r = Bun.spawnSync(['git', 'ls-files', '-z'], { cwd: root, stderr: 'pipe' })
915
+ if (r.exitCode === 0) return new Set(r.stdout.toString().split('\0').filter(Boolean))
916
+ const repo = Bun.spawnSync(['git', 'rev-parse', '--git-dir'], {
917
+ cwd: root,
918
+ stdout: 'ignore',
919
+ stderr: 'ignore',
920
+ })
921
+ if (repo.exitCode !== 0) return new Set()
922
+ // A repository git cannot list (a corrupt index): read as none tracked,
923
+ // clearing a task's outputs deleted its committed files.
924
+ throw new Error(`git ls-files: ${r.stderr.toString().trim()}`)
925
+ }
926
+
927
+ /** Memory a task can take: Linux's MemAvailable counts the page cache it may reclaim, free memory does not. */
928
+ function available(): number {
929
+ try {
930
+ const m = /^MemAvailable:\s+(\d+) kB$/m.exec(readFileSync('/proc/meminfo', 'utf8'))
931
+ if (m !== null) return Number(m[1]) * 1024
932
+ // quiet: no /proc: the machine's total memory stands
933
+ } catch {
934
+ // Not Linux.
935
+ }
936
+ return os.freemem()
937
+ }
938
+
939
+ /**
940
+ * The orchestrator's env for a task, made this machine's: its checkout root
941
+ * swapped for ours, host names from our own env, and PATH our own behind
942
+ * the workspace's bin directories.
943
+ */
944
+ export function localEnv(
945
+ env: Readonly<Record<string, string>>,
946
+ remoteRoot: string,
947
+ root: string,
948
+ ): Record<string, string | undefined> {
949
+ // By path: a plain replace moved `/ws2` and `/private/ws` with `/ws`.
950
+ const out: Record<string, string | undefined> = swapRoots({ ...env }, [[remoteRoot, root]])
951
+ // Sent only when the task's `env.define` names it: its value, as keyed.
952
+ // Else this machine's manager, as core sets it on a local task.
953
+ if (!('npm_execpath' in out)) {
954
+ const manager = packageManagerPath(root)
955
+ if (manager !== null) out['npm_execpath'] = manager
956
+ }
957
+ for (const k of HOST_ENV) if (!(k in out)) out[k] = process.env[k]
958
+ const bins = (out['PATH'] ?? '').split(':').filter((p) => p === root || p.startsWith(`${root}/`))
959
+ out['PATH'] = [...bins, process.env['PATH'] ?? ''].join(':')
960
+ return out
961
+ }
962
+
963
+ /**
964
+ * `value` with each path under a `from` root moved under its `to`, in one
965
+ * pass: a root that holds another (`/ws`, `/ws2`) is not swapped twice. A
966
+ * root ends at `/`, `:` (a PATH list) or the string's end.
967
+ */
968
+ export function swapRoots<T>(value: T, pairs: readonly (readonly [string, string])[]): T {
969
+ // The first pair naming a root wins.
970
+ const to = new Map([...pairs].reverse().map(([f, t]) => [esc(f), esc(t)]))
971
+ const alt = [...to.keys()]
972
+ .sort((a, b) => b.length - a.length)
973
+ .map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
974
+ .join('|')
975
+ const text = JSON.stringify(value).replace(
976
+ new RegExp(`(?<=^|[^\\w.-])(${alt})(?=[/":]|\\\\|$)`, 'g'),
977
+ (m) => to.get(m)!,
978
+ )
979
+ return JSON.parse(text) as T
980
+ }
981
+
982
+ /** A path as JSON spells it inside a string. */
983
+ const esc = (p: string): string => JSON.stringify(p).slice(1, -1)
984
+
985
+ /** Makes each bind that is missing here: the empty files it made, to take back. */
986
+ const gb = (n: number): string => `${(n / 1e9).toFixed(1)} GB`
987
+
988
+ /** The most of a result one send carries: a relay frame is 1 MiB, which sealing grows by a third. */
989
+ const PART_BYTES = 256 * 1024
990
+
991
+ /** `output` cut so each chunk fits a part, escaped as JSON grows it up to six times. */
992
+ function pieces(output: readonly CapturedChunk[]): CapturedChunk[] {
993
+ const most = PART_BYTES / 8
994
+ return output.flatMap((c) => {
995
+ if (c.text.length <= most) return [c]
996
+ const cut: CapturedChunk[] = []
997
+ for (let i = 0; i < c.text.length;) {
998
+ let end = Math.min(i + most, c.text.length)
999
+ // Not inside a surrogate pair: each half replayed alone printed as
1000
+ // two replacement characters (2026-10-25).
1001
+ const last = c.text.charCodeAt(end - 1)
1002
+ if (end < c.text.length && last >= 0xd800 && last <= 0xdbff) end--
1003
+ cut.push({ ...c, text: c.text.slice(i, end) })
1004
+ i = end
1005
+ }
1006
+ return cut
1007
+ })
1008
+ }
1009
+
1010
+ /**
1011
+ * `end` as its sends: itself when it fits one frame, else its long lists
1012
+ * first, in parts that each fit, and then the rest of it.
1013
+ */
1014
+ function parts(end: Extract<ToOrchestrator, { type: 'end' }>): ToOrchestrator[] {
1015
+ if (bytesOf(end) <= PART_BYTES) return [end]
1016
+ const out: ToOrchestrator[] = []
1017
+ const result: Record<string, unknown> = { ...end.result }
1018
+ for (const list of PART_LISTS) {
1019
+ const items = end.result[list] as readonly unknown[] | undefined
1020
+ if (items === undefined) continue
1021
+ delete result[list]
1022
+ let piece: unknown[] = []
1023
+ let bytes = 0
1024
+ for (const item of items) {
1025
+ const size = bytesOf(item) + 1
1026
+ if (piece.length > 0 && bytes + size > PART_BYTES) {
1027
+ out.push({ type: 'part', task: end.task, [list]: piece })
1028
+ piece = []
1029
+ bytes = 0
1030
+ }
1031
+ piece.push(item)
1032
+ bytes += size
1033
+ }
1034
+ if (piece.length > 0) out.push({ type: 'part', task: end.task, [list]: piece })
1035
+ }
1036
+ out.push({ ...end, result: result as WiredResult })
1037
+ return out
1038
+ }