@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/LICENSE +21 -0
- package/README.md +923 -1
- package/index.ts +4 -0
- package/package.json +40 -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
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
|
+
}
|