@vzn/vx-dte 0.0.0 → 0.0.650
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 +1018 -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 +78 -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/trace.ts
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// A distributed run in time, for `vx run --profile` (2026-10-23: "a
|
|
2
|
+
// Chrome profile with all workers and tasks they executed in time"): Trace
|
|
3
|
+
// Event Format events, one process per machine.
|
|
4
|
+
// A worker's first row is its life (warm-up, idle, leave); each task gets
|
|
5
|
+
// the first row free over its time there. The orchestrator's rows hold
|
|
6
|
+
// what it asked and when, the tasks it held waiting for room, and its
|
|
7
|
+
// restores. Times are this machine's, so a worker's are when it heard.
|
|
8
|
+
|
|
9
|
+
/** One slice: a span of a row, in `performance.now()` ms. */
|
|
10
|
+
interface Slice {
|
|
11
|
+
readonly track: string
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly cat: string
|
|
14
|
+
readonly from: number
|
|
15
|
+
readonly to: number
|
|
16
|
+
readonly args?: Record<string, unknown>
|
|
17
|
+
/** Slices inside it, on its row. */
|
|
18
|
+
readonly parts?: readonly { name: string; from: number; to: number }[]
|
|
19
|
+
/** Its first row, not one of its own: a worker's life. */
|
|
20
|
+
readonly life?: true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Mark {
|
|
24
|
+
readonly track: string
|
|
25
|
+
readonly name: string
|
|
26
|
+
readonly at: number
|
|
27
|
+
readonly args?: Record<string, unknown>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A task's cores on its worker over its time: the counters. */
|
|
31
|
+
interface Use {
|
|
32
|
+
readonly track: string
|
|
33
|
+
readonly from: number
|
|
34
|
+
readonly to: number
|
|
35
|
+
readonly booked: number
|
|
36
|
+
readonly used: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Event = Record<string, unknown>
|
|
40
|
+
|
|
41
|
+
export const ORCHESTRATOR = 'orchestrator'
|
|
42
|
+
|
|
43
|
+
export class Trace {
|
|
44
|
+
private readonly slices: Slice[] = []
|
|
45
|
+
private readonly marks: Mark[] = []
|
|
46
|
+
private readonly uses: Use[] = []
|
|
47
|
+
/** Each worker's machine: cores busy, as it reported, task or not. */
|
|
48
|
+
private readonly loads: {
|
|
49
|
+
readonly track: string
|
|
50
|
+
readonly at: number
|
|
51
|
+
readonly cpu: number
|
|
52
|
+
readonly tasks: number | undefined
|
|
53
|
+
}[] = []
|
|
54
|
+
/** Each worker's time online: idle is what its tasks leave of it. */
|
|
55
|
+
private readonly online = new Map<string, { from: number; to?: number }>()
|
|
56
|
+
/** `performance.now()` here when `Date.now()` was `wall`: a worker's own clock to ours. */
|
|
57
|
+
private readonly wall = Date.now() - performance.now()
|
|
58
|
+
|
|
59
|
+
slice(s: Slice): void {
|
|
60
|
+
if (s.to >= s.from) this.slices.push(s)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
mark(track: string, name: string, args?: Record<string, unknown>, at = performance.now()): void {
|
|
64
|
+
this.marks.push({ track, name, at, ...(args === undefined ? {} : { args }) })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
use(u: Use): void {
|
|
68
|
+
if (u.to > u.from) this.uses.push(u)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Worker `track`'s machine had `cpu` cores busy at `at`, its tasks' or
|
|
73
|
+
* not, and its running tasks' process trees `tasks` of them: a Mac
|
|
74
|
+
* whose tasks used 0.65 cores read 2.3 busy and queued a 1.67-core
|
|
75
|
+
* build 65 s (main b141dd5, 2026-10-24).
|
|
76
|
+
*/
|
|
77
|
+
load(track: string, cpu: number, at = performance.now(), tasks?: number): void {
|
|
78
|
+
const last = this.loads.findLast((l) => l.track === track)
|
|
79
|
+
if (last?.cpu !== cpu || last.tasks !== tasks) this.loads.push({ track, at, cpu, tasks })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Worker `track` joined at `at`, asked for at `askedAt` when this run
|
|
84
|
+
* started it; `booted` is the wall-clock time its `vx` started, which
|
|
85
|
+
* splits its warm-up into its machine's start (a CI queue, a checkout,
|
|
86
|
+
* an install) and vx's own boot.
|
|
87
|
+
*/
|
|
88
|
+
joined(track: string, at: number, askedAt?: number, booted?: number): void {
|
|
89
|
+
this.online.set(track, { from: at })
|
|
90
|
+
if (askedAt === undefined) return
|
|
91
|
+
const boot = booted === undefined ? undefined : booted - this.wall
|
|
92
|
+
const split = boot !== undefined && boot > askedAt && boot < at
|
|
93
|
+
this.slice({
|
|
94
|
+
track,
|
|
95
|
+
name: 'warm-up',
|
|
96
|
+
cat: 'worker',
|
|
97
|
+
from: askedAt,
|
|
98
|
+
to: at,
|
|
99
|
+
life: true,
|
|
100
|
+
...(split
|
|
101
|
+
? {
|
|
102
|
+
parts: [
|
|
103
|
+
{ name: 'machine start', from: askedAt, to: boot },
|
|
104
|
+
{ name: 'vx boot + join', from: boot, to: at },
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
: {}),
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Worker `track` left (`how`: left, lost, stopped) at `at`. */
|
|
112
|
+
left(track: string, how: string, at = performance.now()): void {
|
|
113
|
+
const o = this.online.get(track)
|
|
114
|
+
if (o === undefined || o.to !== undefined) return
|
|
115
|
+
o.to = at
|
|
116
|
+
this.mark(track, how, undefined, at)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A worker asked for that never joined, from its ask to `at`. */
|
|
120
|
+
unstarted(track: string, askedAt: number, why: string, at = performance.now()): void {
|
|
121
|
+
this.slice({
|
|
122
|
+
track,
|
|
123
|
+
name: 'warm-up',
|
|
124
|
+
cat: 'worker',
|
|
125
|
+
from: askedAt,
|
|
126
|
+
to: at,
|
|
127
|
+
life: true,
|
|
128
|
+
args: { failed: why },
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The run's events, ended at `end`: `ts` in microseconds of `performance.now()` (`TaskExecutor.profile`). */
|
|
133
|
+
events(end: number): Event[] {
|
|
134
|
+
const us = (ms: number): number => Math.round(ms * 1000)
|
|
135
|
+
const tracks = [
|
|
136
|
+
ORCHESTRATOR,
|
|
137
|
+
...new Set(
|
|
138
|
+
// Every source of a row: a worker with no slice yet still has its idle and counters.
|
|
139
|
+
[
|
|
140
|
+
...this.slices.map((s) => s.track),
|
|
141
|
+
...this.marks.map((m) => m.track),
|
|
142
|
+
...this.online.keys(),
|
|
143
|
+
...this.uses.map((u) => u.track),
|
|
144
|
+
]
|
|
145
|
+
.filter((t) => t !== ORCHESTRATOR)
|
|
146
|
+
.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })),
|
|
147
|
+
),
|
|
148
|
+
]
|
|
149
|
+
const pid = new Map(tracks.map((t, i) => [t, i + 1]))
|
|
150
|
+
const events: Event[] = []
|
|
151
|
+
for (const [t, p] of pid) {
|
|
152
|
+
events.push({ ph: 'M', name: 'process_name', pid: p, tid: 0, args: { name: t } })
|
|
153
|
+
events.push({ ph: 'M', name: 'process_sort_index', pid: p, tid: 0, args: { sort_index: p } })
|
|
154
|
+
events.push({
|
|
155
|
+
ph: 'M',
|
|
156
|
+
name: 'thread_name',
|
|
157
|
+
pid: p,
|
|
158
|
+
tid: 0,
|
|
159
|
+
args: { name: t === ORCHESTRATOR ? 'run' : 'worker' },
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
// Idle: a worker's time online that no task of its covers.
|
|
163
|
+
const idle: Slice[] = []
|
|
164
|
+
for (const [track, o] of this.online) {
|
|
165
|
+
const busy = this.slices
|
|
166
|
+
.filter((s) => s.track === track && s.life !== true)
|
|
167
|
+
.map((s) => [s.from, s.to] as const)
|
|
168
|
+
.sort((a, b) => a[0] - b[0])
|
|
169
|
+
let at = o.from
|
|
170
|
+
const until = o.to ?? end
|
|
171
|
+
for (const [from, to] of busy) {
|
|
172
|
+
if (from > at)
|
|
173
|
+
idle.push({
|
|
174
|
+
track,
|
|
175
|
+
name: 'idle',
|
|
176
|
+
cat: 'idle',
|
|
177
|
+
from: at,
|
|
178
|
+
to: Math.min(from, until),
|
|
179
|
+
life: true,
|
|
180
|
+
})
|
|
181
|
+
at = Math.max(at, to)
|
|
182
|
+
}
|
|
183
|
+
if (until > at)
|
|
184
|
+
idle.push({ track, name: 'idle', cat: 'idle', from: at, to: until, life: true })
|
|
185
|
+
}
|
|
186
|
+
// Rows by first free, one set per kind (a worker's tasks, the run's
|
|
187
|
+
// waits, its restores): a slice takes the lowest of its kind whose last ended.
|
|
188
|
+
const rows = new Map<string, { tid: number; end: number }[]>()
|
|
189
|
+
const tids = new Map<string, number>()
|
|
190
|
+
for (const s of [...this.slices, ...idle].sort((a, b) => a.from - b.from)) {
|
|
191
|
+
const p = pid.get(s.track)!
|
|
192
|
+
let tid = 0
|
|
193
|
+
if (s.life !== true) {
|
|
194
|
+
const kind = `${s.track}\0${s.cat}`
|
|
195
|
+
const free = rows.get(kind) ?? []
|
|
196
|
+
rows.set(kind, free)
|
|
197
|
+
let row = free.find((r) => r.end <= s.from)
|
|
198
|
+
if (row === undefined) {
|
|
199
|
+
tid = (tids.get(s.track) ?? 0) + 1
|
|
200
|
+
tids.set(s.track, tid)
|
|
201
|
+
row = { tid, end: 0 }
|
|
202
|
+
free.push(row)
|
|
203
|
+
events.push({
|
|
204
|
+
ph: 'M',
|
|
205
|
+
name: 'thread_name',
|
|
206
|
+
pid: p,
|
|
207
|
+
tid,
|
|
208
|
+
args: { name: `${s.cat} ${free.length}` },
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
row.end = s.to
|
|
212
|
+
tid = row.tid
|
|
213
|
+
}
|
|
214
|
+
events.push({
|
|
215
|
+
ph: 'X',
|
|
216
|
+
name: s.name,
|
|
217
|
+
cat: s.cat,
|
|
218
|
+
pid: p,
|
|
219
|
+
tid,
|
|
220
|
+
ts: us(s.from),
|
|
221
|
+
dur: Math.max(0, us(s.to) - us(s.from)),
|
|
222
|
+
...(s.args === undefined ? {} : { args: s.args }),
|
|
223
|
+
})
|
|
224
|
+
for (const part of s.parts ?? [])
|
|
225
|
+
if (part.to > part.from)
|
|
226
|
+
events.push({
|
|
227
|
+
ph: 'X',
|
|
228
|
+
name: part.name,
|
|
229
|
+
cat: s.cat,
|
|
230
|
+
pid: p,
|
|
231
|
+
tid,
|
|
232
|
+
ts: us(part.from),
|
|
233
|
+
dur: us(part.to) - us(part.from),
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
for (const m of this.marks)
|
|
237
|
+
events.push({
|
|
238
|
+
ph: 'i',
|
|
239
|
+
s: 'p',
|
|
240
|
+
name: m.name,
|
|
241
|
+
cat: 'mark',
|
|
242
|
+
pid: pid.get(m.track)!,
|
|
243
|
+
tid: 0,
|
|
244
|
+
ts: us(m.at),
|
|
245
|
+
...(m.args === undefined ? {} : { args: m.args }),
|
|
246
|
+
})
|
|
247
|
+
// Each worker's cores: booked by the run and used by its tasks, as they change.
|
|
248
|
+
for (const track of new Set(this.uses.map((u) => u.track))) {
|
|
249
|
+
const steps = this.uses
|
|
250
|
+
.filter((u) => u.track === track)
|
|
251
|
+
.flatMap((u) => [
|
|
252
|
+
[u.from, u.booked, u.used],
|
|
253
|
+
[u.to, -u.booked, -u.used],
|
|
254
|
+
])
|
|
255
|
+
.sort((a, b) => a[0]! - b[0]!)
|
|
256
|
+
let booked = 0
|
|
257
|
+
let used = 0
|
|
258
|
+
for (const [at, b, u] of steps) {
|
|
259
|
+
booked += b!
|
|
260
|
+
used += u!
|
|
261
|
+
events.push({
|
|
262
|
+
ph: 'C',
|
|
263
|
+
name: 'cores',
|
|
264
|
+
pid: pid.get(track)!,
|
|
265
|
+
tid: 0,
|
|
266
|
+
ts: us(at!),
|
|
267
|
+
args: { booked: round(booked), used: round(used) },
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
for (const l of this.loads) {
|
|
272
|
+
const id = pid.get(l.track)
|
|
273
|
+
if (id !== undefined)
|
|
274
|
+
events.push({
|
|
275
|
+
ph: 'C',
|
|
276
|
+
name: 'machine',
|
|
277
|
+
pid: id,
|
|
278
|
+
tid: 0,
|
|
279
|
+
ts: us(l.at),
|
|
280
|
+
args: { busy: round(l.cpu), ...(l.tasks === undefined ? {} : { tasks: round(l.tasks) }) },
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
return events
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Two decimals, and no `-0` or float dust from adding and taking away. */
|
|
288
|
+
const round = (n: number): number => Math.max(0, Math.round(n * 100) / 100)
|
package/src/tree.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// What a worker must have checked out: the tracked files as the run sees
|
|
2
|
+
// them. Workers compare trees, not commits, so a run on uncommitted changes
|
|
3
|
+
// still matches the worktrees `local()` makes from them.
|
|
4
|
+
|
|
5
|
+
import { copyFile, mkdtemp, rm, stat, utimes } from 'node:fs/promises'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A commit of the tracked files as they are now (`HEAD`'s when none changed),
|
|
11
|
+
* and its tree; both empty when git cannot say.
|
|
12
|
+
*/
|
|
13
|
+
export async function snapshot(root: string): Promise<{ commit: string; tree: string }> {
|
|
14
|
+
const none = { commit: '', tree: '' }
|
|
15
|
+
const head = (await git(['rev-parse', '--verify', '-q', 'HEAD^{commit}'], root))?.trim()
|
|
16
|
+
if (head === undefined || head === '') return none
|
|
17
|
+
// The working tree's tracked files staged into a copy of the index, which
|
|
18
|
+
// leaves the index and the working tree as they are. `git stash create`
|
|
19
|
+
// did the same, but refused an intent-to-add entry (`git add -N`), and
|
|
20
|
+
// HEAD stood in for the changes (2026-10-28).
|
|
21
|
+
const index = (
|
|
22
|
+
await git(['rev-parse', '--path-format=absolute', '--git-path', 'index'], root)
|
|
23
|
+
)?.trim()
|
|
24
|
+
if (index === undefined) return none
|
|
25
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'vx-dte-index-'))
|
|
26
|
+
try {
|
|
27
|
+
const env = { GIT_INDEX_FILE: path.join(dir, 'index') }
|
|
28
|
+
try {
|
|
29
|
+
await copyFile(index, env.GIT_INDEX_FILE)
|
|
30
|
+
// The copy keeps the index's mtime (as core's refreshedIndex): git
|
|
31
|
+
// trusts an entry's stat only while its file is older than the index,
|
|
32
|
+
// and a fresh mtime took a same-size edit made in the tick the index
|
|
33
|
+
// was written for clean, its old blob in the tree (2026-10-29).
|
|
34
|
+
const st = await stat(index)
|
|
35
|
+
await utimes(env.GIT_INDEX_FILE, st.atime, st.mtime)
|
|
36
|
+
} catch {
|
|
37
|
+
// quiet: no index yet, so HEAD's tree is its tracked set.
|
|
38
|
+
if ((await git(['read-tree', head], root, env)) === undefined) return none
|
|
39
|
+
}
|
|
40
|
+
if ((await git(['add', '-u', '--', ':/'], root, env)) === undefined) return none
|
|
41
|
+
const tree = (await git(['write-tree'], root, env))?.trim()
|
|
42
|
+
if (tree === undefined || tree === '') return none
|
|
43
|
+
const was = (await git(['rev-parse', `${head}^{tree}`], root))?.trim()
|
|
44
|
+
if (tree === was) return { commit: head, tree }
|
|
45
|
+
// The commit is nobody's, so it needs no one's identity: a CI runner
|
|
46
|
+
// with none made `git stash create` fail (2026-10-28).
|
|
47
|
+
const commit = (
|
|
48
|
+
await git(['commit-tree', tree, '-p', head, '-m', 'vx dte'], root, IDENTITY)
|
|
49
|
+
)?.trim()
|
|
50
|
+
return commit === undefined || commit === '' ? none : { commit, tree }
|
|
51
|
+
} finally {
|
|
52
|
+
await rm(dir, { recursive: true, force: true })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const IDENTITY = {
|
|
57
|
+
GIT_AUTHOR_NAME: 'vx',
|
|
58
|
+
GIT_AUTHOR_EMAIL: 'vx@localhost',
|
|
59
|
+
GIT_COMMITTER_NAME: 'vx',
|
|
60
|
+
GIT_COMMITTER_EMAIL: 'vx@localhost',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** git's stdout, or undefined when it failed. */
|
|
64
|
+
async function git(
|
|
65
|
+
args: string[],
|
|
66
|
+
cwd: string,
|
|
67
|
+
env?: Record<string, string>,
|
|
68
|
+
): Promise<string | undefined> {
|
|
69
|
+
const p = Bun.spawn(['git', ...args], {
|
|
70
|
+
cwd,
|
|
71
|
+
stdin: 'ignore',
|
|
72
|
+
stdout: 'pipe',
|
|
73
|
+
stderr: 'ignore',
|
|
74
|
+
...(env === undefined ? {} : { env: { ...process.env, ...env } }),
|
|
75
|
+
})
|
|
76
|
+
const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited])
|
|
77
|
+
return code === 0 ? out : undefined
|
|
78
|
+
}
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// What one machine did in a distributed run, from its tasks' results: the
|
|
2
|
+
// final summary's per-worker block.
|
|
3
|
+
|
|
4
|
+
interface Span {
|
|
5
|
+
readonly start: number
|
|
6
|
+
readonly end: number
|
|
7
|
+
readonly cores: number
|
|
8
|
+
readonly memory: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UsageStats {
|
|
12
|
+
readonly onlineMs: number
|
|
13
|
+
/** Time with at least one task running. */
|
|
14
|
+
readonly busyMs: number
|
|
15
|
+
/** Cores and bytes in use at once, at the most and on average over its time online. */
|
|
16
|
+
readonly cpuPeak: number
|
|
17
|
+
readonly cpuAvg: number
|
|
18
|
+
readonly memoryPeak: number
|
|
19
|
+
readonly memoryAvg: number
|
|
20
|
+
/** Core-time its tasks used over the core-time it had. */
|
|
21
|
+
readonly utilization: number
|
|
22
|
+
readonly cpuMs: number
|
|
23
|
+
/** Cores the run held for its tasks, on average over its time online: what packing gave it. */
|
|
24
|
+
readonly bookedAvg: number
|
|
25
|
+
/**
|
|
26
|
+
* Cores busy on its machine, any process's, on average since its worker
|
|
27
|
+
* started, as its last frame said: what `cpuAvg` leaves out (a Mac's
|
|
28
|
+
* indexer, a task's orphans). Undefined when it said none.
|
|
29
|
+
*/
|
|
30
|
+
readonly machineAvg?: number | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class Usage {
|
|
34
|
+
done = 0
|
|
35
|
+
failed = 0
|
|
36
|
+
/** Killed by the run's stop, as core counts a task `aborted`. */
|
|
37
|
+
aborted = 0
|
|
38
|
+
/** Messages and bytes the run sent it, and those it sent the run. */
|
|
39
|
+
messagesIn = 0
|
|
40
|
+
bytesIn = 0
|
|
41
|
+
messagesOut = 0
|
|
42
|
+
bytesOut = 0
|
|
43
|
+
/** When it left, if before the run ended. */
|
|
44
|
+
to: number | undefined
|
|
45
|
+
private readonly spans: Span[] = []
|
|
46
|
+
private booked = 0
|
|
47
|
+
private bookedMs = 0
|
|
48
|
+
private bookedAt = 0
|
|
49
|
+
/** Its machine's busy cores on average, as its last frame said. */
|
|
50
|
+
machineAvg: number | undefined
|
|
51
|
+
|
|
52
|
+
constructor(readonly from: number = performance.now()) {}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A task it finished, as it ends: on it `from` its send (else its
|
|
56
|
+
* command's duration), its CPU time spread over that. Its command alone
|
|
57
|
+
* left the restore of its dependencies and its save idle: a worker
|
|
58
|
+
* busy all along read 10 s idle (2026-10-24).
|
|
59
|
+
*/
|
|
60
|
+
record(
|
|
61
|
+
r: {
|
|
62
|
+
readonly exitCode: number
|
|
63
|
+
readonly signal?: string | number | undefined
|
|
64
|
+
readonly timedOut?: boolean | undefined
|
|
65
|
+
readonly durationMs: number
|
|
66
|
+
readonly cpuMs?: number | undefined
|
|
67
|
+
readonly peakRssBytes?: number | undefined
|
|
68
|
+
},
|
|
69
|
+
from?: number,
|
|
70
|
+
): void {
|
|
71
|
+
if (r.exitCode === 0) this.done++
|
|
72
|
+
else if ((r.signal === 'SIGINT' || r.signal === 'SIGTERM') && r.timedOut !== true)
|
|
73
|
+
this.aborted++
|
|
74
|
+
else this.failed++
|
|
75
|
+
const end = performance.now()
|
|
76
|
+
const ms = Math.max(1, from === undefined ? r.durationMs : end - from)
|
|
77
|
+
this.spans.push({
|
|
78
|
+
start: end - ms,
|
|
79
|
+
end,
|
|
80
|
+
cores: (r.cpuMs ?? 0) / ms,
|
|
81
|
+
memory: r.peakRssBytes ?? 0,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The run now holds `cores` more of it for its tasks (fewer when negative). */
|
|
86
|
+
book(cores: number, at: number = performance.now()): void {
|
|
87
|
+
this.bookedMs += this.booked * (at - this.bookedAt)
|
|
88
|
+
this.bookedAt = at
|
|
89
|
+
this.booked += cores
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
stats(cores: number, until: number): UsageStats {
|
|
93
|
+
// Never before it joined: a time from before would read it online 1 ms.
|
|
94
|
+
const end = Math.max(this.from, this.to ?? until)
|
|
95
|
+
const onlineMs = Math.max(1, end - this.from)
|
|
96
|
+
// Ends before starts at one instant: back to back is not at once.
|
|
97
|
+
const events = this.spans
|
|
98
|
+
.flatMap((s) => [[s.start, 1, s] as const, [s.end, -1, s] as const])
|
|
99
|
+
.sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
|
100
|
+
let running = 0
|
|
101
|
+
let cpu = 0
|
|
102
|
+
let memory = 0
|
|
103
|
+
let cpuPeak = 0
|
|
104
|
+
let memoryPeak = 0
|
|
105
|
+
let busyMs = 0
|
|
106
|
+
let last = 0
|
|
107
|
+
for (const [at, d, s] of events) {
|
|
108
|
+
if (running > 0) busyMs += at - last
|
|
109
|
+
last = at
|
|
110
|
+
running += d
|
|
111
|
+
cpu += d * s.cores
|
|
112
|
+
memory += d * s.memory
|
|
113
|
+
cpuPeak = Math.max(cpuPeak, cpu)
|
|
114
|
+
memoryPeak = Math.max(memoryPeak, memory)
|
|
115
|
+
}
|
|
116
|
+
let cpuMs = 0
|
|
117
|
+
let memoryMs = 0
|
|
118
|
+
for (const s of this.spans) {
|
|
119
|
+
cpuMs += s.cores * (s.end - s.start)
|
|
120
|
+
memoryMs += s.memory * (s.end - s.start)
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
onlineMs,
|
|
124
|
+
busyMs,
|
|
125
|
+
cpuPeak,
|
|
126
|
+
cpuAvg: cpuMs / onlineMs,
|
|
127
|
+
memoryPeak,
|
|
128
|
+
memoryAvg: memoryMs / onlineMs,
|
|
129
|
+
utilization: cpuMs / (cores * onlineMs),
|
|
130
|
+
cpuMs,
|
|
131
|
+
bookedAvg: (this.bookedMs + this.booked * Math.max(0, end - this.bookedAt)) / onlineMs,
|
|
132
|
+
machineAvg: this.machineAvg,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/warm-ups.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Each size's warm-up across runs, through the workspace's cache (M,
|
|
2
|
+
// 2026-10-23: "planner needs to use warmup from history"): a CI run writes
|
|
3
|
+
// the least time a worker of each size took to join under its commit, over
|
|
4
|
+
// the sizes it read; a run reads its newest first-parent ancestor's. Before
|
|
5
|
+
// any worker of a size joins, the plan waits that long for one, not 20 s:
|
|
6
|
+
// main fd5c72e's small workers joined in 15 s, its mac in 27 s. The blob is
|
|
7
|
+
// untrusted: it only sizes a plan, and a bad one reads as none.
|
|
8
|
+
|
|
9
|
+
import type { PluginCache } from '@vzn/vx'
|
|
10
|
+
|
|
11
|
+
/** Commits walked back from HEAD for a blob. */
|
|
12
|
+
const WALK = 6
|
|
13
|
+
const MAX_BYTES = 64 * 1024
|
|
14
|
+
const MAX_SIZES = 256
|
|
15
|
+
const MAX_MS = 3_600_000
|
|
16
|
+
|
|
17
|
+
const keyOf = (sha: string): string => `warm-ups\0${sha}`
|
|
18
|
+
|
|
19
|
+
/** `{ version: 1, sizes: { name: ms } }`'s warm-ups, each ms finite in [0, 1 h]; null for anything else. */
|
|
20
|
+
export function parseWarmUps(bytes: Uint8Array): Map<string, number> | null {
|
|
21
|
+
if (bytes.byteLength > MAX_BYTES) return null
|
|
22
|
+
let parsed: unknown
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(new TextDecoder().decode(bytes))
|
|
25
|
+
// quiet: not JSON reads as no blob
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
30
|
+
const { version, sizes } = parsed as { version?: unknown; sizes?: unknown }
|
|
31
|
+
if (version !== 1 || typeof sizes !== 'object' || sizes === null || Array.isArray(sizes))
|
|
32
|
+
return null
|
|
33
|
+
const entries = Object.entries(sizes)
|
|
34
|
+
if (entries.length > MAX_SIZES) return null
|
|
35
|
+
const out = new Map<string, number>()
|
|
36
|
+
for (const [name, ms] of entries)
|
|
37
|
+
if (typeof ms === 'number' && Number.isFinite(ms) && ms >= 0 && ms <= MAX_MS) out.set(name, ms)
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** HEAD and its first-parent ancestors, newest first; none outside git. */
|
|
42
|
+
async function commits(root: string): Promise<string[]> {
|
|
43
|
+
try {
|
|
44
|
+
const proc = Bun.spawn({
|
|
45
|
+
cmd: ['git', 'rev-list', '--first-parent', `--max-count=${WALK}`, 'HEAD'],
|
|
46
|
+
cwd: root,
|
|
47
|
+
stdout: 'pipe',
|
|
48
|
+
stderr: 'ignore',
|
|
49
|
+
})
|
|
50
|
+
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
51
|
+
return exit === 0 ? out.split('\n').filter((l) => l !== '') : []
|
|
52
|
+
// quiet: a local git read; no commits reads no blob
|
|
53
|
+
} catch {
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The newest warm-ups an ancestor's run wrote, the base branch's on a scoped run. */
|
|
59
|
+
export async function readWarmUps(cache: PluginCache, root: string): Promise<Map<string, number>> {
|
|
60
|
+
const keys = (await commits(root)).map(keyOf)
|
|
61
|
+
if (keys.length === 0) return new Map()
|
|
62
|
+
const held = await cache.hasMany(keys, false)
|
|
63
|
+
for (const key of keys) {
|
|
64
|
+
if (held !== null && !held.has(key)) continue
|
|
65
|
+
const bytes = await cache.get(key, false)
|
|
66
|
+
const got = bytes === null ? null : parseWarmUps(bytes)
|
|
67
|
+
if (got !== null) return got
|
|
68
|
+
}
|
|
69
|
+
return new Map()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `learned` with this run's own over it, under HEAD: once per commit, as every entry is. */
|
|
73
|
+
export async function writeWarmUps(
|
|
74
|
+
cache: PluginCache,
|
|
75
|
+
root: string,
|
|
76
|
+
learned: ReadonlyMap<string, number>,
|
|
77
|
+
run: ReadonlyMap<string, number>,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
if (run.size === 0) return
|
|
80
|
+
const [head] = await commits(root)
|
|
81
|
+
if (head === undefined) return
|
|
82
|
+
const sizes: Record<string, number> = {}
|
|
83
|
+
for (const [name, ms] of [...learned, ...run]) sizes[name] = Math.round(ms)
|
|
84
|
+
await cache.put(keyOf(head), new TextEncoder().encode(JSON.stringify({ version: 1, sizes })))
|
|
85
|
+
}
|