@vzn/vx-dte 0.0.651 → 0.0.653
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/README.md +164 -60
- package/package.json +3 -3
- package/src/dispatch.ts +540 -30
- package/src/forecast.ts +1 -2
- package/src/github.ts +0 -3
- package/src/index.ts +1 -0
- package/src/init.ts +9 -10
- package/src/local.ts +112 -28
- package/src/messages.ts +26 -5
- package/src/orchestrator.ts +678 -233
- package/src/planner.ts +1470 -192
- package/src/plugin.ts +242 -33
- package/src/seal.ts +159 -43
- package/src/server-fit.ts +80 -0
- package/src/tree.ts +215 -7
- package/src/usage.ts +65 -3
- package/src/warm-ups.ts +72 -6
- package/src/wire.ts +119 -49
- package/src/worker.ts +174 -19
package/src/worker.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
HOST_ENV,
|
|
37
37
|
PART_LISTS,
|
|
38
38
|
type DepOrder,
|
|
39
|
+
type WiredDep,
|
|
39
40
|
type Load,
|
|
40
41
|
type WorkerInfo,
|
|
41
42
|
type RunOrder,
|
|
@@ -44,7 +45,8 @@ import {
|
|
|
44
45
|
type WiredResult,
|
|
45
46
|
} from './messages.js'
|
|
46
47
|
import { leftUnder, names, outputFiles } from './outputs.js'
|
|
47
|
-
import {
|
|
48
|
+
import { serverFit } from './server-fit.js'
|
|
49
|
+
import { snapshot, unfilter } from './tree.js'
|
|
48
50
|
import { sealWorker, type Proof } from './seal.js'
|
|
49
51
|
import { join, ORCHESTRATOR_SILENT, RelayError, unsent as unsentBy } from './wire.js'
|
|
50
52
|
|
|
@@ -65,6 +67,8 @@ export async function runWorker(opts: {
|
|
|
65
67
|
// The tracked files as they are, as the run snapshots its own: HEAD's
|
|
66
68
|
// tree let in a checkout whose prepare rewrote a tracked file, and the
|
|
67
69
|
// task built from it was saved under the clean key (2026-10-25).
|
|
70
|
+
// Its files first as the commit holds them, as the run's key read them.
|
|
71
|
+
await unfilter(opts.root)
|
|
68
72
|
const base = await snapshot(opts.root)
|
|
69
73
|
const tree = base.tree
|
|
70
74
|
// Never an output, as core never cleans one: the checkout is the run's
|
|
@@ -114,7 +118,9 @@ export async function runWorker(opts: {
|
|
|
114
118
|
const artifacts = opts.artifacts(() => facts)
|
|
115
119
|
const running = new Map<string, AbortController>()
|
|
116
120
|
/** Each order's deps sent ahead of its batch, until the batch comes. */
|
|
117
|
-
const ahead = new Map<string,
|
|
121
|
+
const ahead = new Map<string, WiredDep[]>()
|
|
122
|
+
/** Each dependency sent whole, by its task: an order after names it alone (`WiredDep`). */
|
|
123
|
+
const known = new Map<string, DepOrder>()
|
|
118
124
|
const work = new Set<Promise<void>>()
|
|
119
125
|
let root = opts.root
|
|
120
126
|
let realRoot = opts.root
|
|
@@ -129,7 +135,40 @@ export async function runWorker(opts: {
|
|
|
129
135
|
}
|
|
130
136
|
let tasksCpuMs = 0
|
|
131
137
|
const trees = treeMeter()
|
|
132
|
-
|
|
138
|
+
// Its servers' trees, and which dependants each serves now: their CPU is
|
|
139
|
+
// charged to those tasks, and each base reported (`serverFit`).
|
|
140
|
+
const serverTrees = treeMeter()
|
|
141
|
+
const fit = serverFit()
|
|
142
|
+
const frame = (): Load => {
|
|
143
|
+
const servers = fit.bases()
|
|
144
|
+
return {
|
|
145
|
+
...load.now(),
|
|
146
|
+
tasks: trees.now(),
|
|
147
|
+
...(Object.keys(servers).length === 0 ? {} : { servers }),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const usersOf = new Map<string, Set<string>>()
|
|
151
|
+
let sampledAt = performance.now()
|
|
152
|
+
const sampler = setInterval(() => {
|
|
153
|
+
const at = performance.now()
|
|
154
|
+
const ms = at - sampledAt
|
|
155
|
+
sampledAt = at
|
|
156
|
+
if (usersOf.size === 0) return
|
|
157
|
+
const capped = load.now().cpu >= cores * FULL
|
|
158
|
+
for (const [server, users] of usersOf)
|
|
159
|
+
fit.sample(server, [...users], serverTrees.coresOf(server), ms, capped)
|
|
160
|
+
}, TREE_EVERY_MS)
|
|
161
|
+
sampler.unref?.()
|
|
162
|
+
const newFloor = (sandboxes: Parameters<typeof localFloor>[0] = []): LocalFloor =>
|
|
163
|
+
localFloor(sandboxes, (id, pid) => {
|
|
164
|
+
if (pid === undefined) {
|
|
165
|
+
serverTrees.drop(id)
|
|
166
|
+
usersOf.delete(id)
|
|
167
|
+
} else {
|
|
168
|
+
serverTrees.watch(id, pid)
|
|
169
|
+
usersOf.set(id, usersOf.get(id) ?? new Set())
|
|
170
|
+
}
|
|
171
|
+
})
|
|
133
172
|
/**
|
|
134
173
|
* Entries whose files are here, restored or run here, by key: tasks
|
|
135
174
|
* started together wait on one restore, never run before it.
|
|
@@ -440,6 +479,9 @@ export async function runWorker(opts: {
|
|
|
440
479
|
? undefined
|
|
441
480
|
: new Set(m.sandbox.keyed.map((k) => path.join(opts.root, k))),
|
|
442
481
|
m.nested.map((n) => path.join(opts.root, n)),
|
|
482
|
+
undefined,
|
|
483
|
+
(m.sandbox.reads ?? []).map((r) => path.join(opts.root, r)),
|
|
484
|
+
(m.sandbox.reported ?? []).map((r) => path.join(opts.root, r)),
|
|
443
485
|
)
|
|
444
486
|
const output = new BoundedCapture()
|
|
445
487
|
const req: ExecuteRequest = {
|
|
@@ -448,7 +490,7 @@ export async function runWorker(opts: {
|
|
|
448
490
|
command: m.command,
|
|
449
491
|
forwardArgs: [],
|
|
450
492
|
cwd: path.join(opts.root, m.cwd),
|
|
451
|
-
env: localEnv(m.env, root, opts.root),
|
|
493
|
+
env: localEnv(m.env, root, opts.root, m.unset),
|
|
452
494
|
envDefine: {},
|
|
453
495
|
// Both streams as one capture, in the order written, as core keeps a
|
|
454
496
|
// run's: kept apart, a hit replayed every stdout line before the
|
|
@@ -482,16 +524,40 @@ export async function runWorker(opts: {
|
|
|
482
524
|
})),
|
|
483
525
|
}),
|
|
484
526
|
}
|
|
485
|
-
|
|
527
|
+
// An untracked file under its project is one the orchestrator's key
|
|
528
|
+
// never read (it refuses one it keys), yet the command may: one a
|
|
529
|
+
// checkout left built into the output saved under the clean key, and
|
|
530
|
+
// every later run restored it (2026-11-07). Listed before the command,
|
|
531
|
+
// so a file the task writes is not taken for one it read.
|
|
532
|
+
const strays = await untrackedUnder(opts.root, m, [names(m.project, m.outputs), ...upstream])
|
|
533
|
+
floor ??= newFloor()
|
|
486
534
|
ui.print('out', ui.status('info', m.task, ui.dim('running')))
|
|
487
535
|
const crowd = load.mark()
|
|
536
|
+
const serves = (m.servers ?? []).map((sv) => sv.task)
|
|
537
|
+
// Counted a user once its server is up: `onServer` makes the set.
|
|
538
|
+
const using = (): void => {
|
|
539
|
+
for (const sv of serves) usersOf.get(sv)?.add(m.task)
|
|
540
|
+
}
|
|
488
541
|
const {
|
|
489
542
|
outputs: _,
|
|
490
543
|
stdout: _o,
|
|
491
544
|
stderr: _e,
|
|
492
545
|
...res
|
|
493
|
-
} = await floor
|
|
546
|
+
} = await floor
|
|
547
|
+
.execute({
|
|
548
|
+
...req,
|
|
549
|
+
onSpawn: (pid) => {
|
|
550
|
+
using()
|
|
551
|
+
req.onSpawn?.(pid)
|
|
552
|
+
},
|
|
553
|
+
})
|
|
554
|
+
.finally(() => {
|
|
555
|
+
trees.drop(m.task)
|
|
556
|
+
for (const sv of serves) usersOf.get(sv)?.delete(m.task)
|
|
557
|
+
})
|
|
494
558
|
tasksCpuMs += res.cpuMs ?? 0
|
|
559
|
+
// What its servers did for it: its CPU as history books it.
|
|
560
|
+
const served = fit.take(m.task)
|
|
495
561
|
const cpuShare = load.shareSince(
|
|
496
562
|
crowd,
|
|
497
563
|
res.cpuMs === undefined || !(res.durationMs > 0) ? undefined : res.cpuMs / res.durationMs,
|
|
@@ -534,7 +600,10 @@ export async function runWorker(opts: {
|
|
|
534
600
|
// it; this entry is withheld for any. Nor for an additive task that
|
|
535
601
|
// removed an upstream's file: a hit left the file in place, and core
|
|
536
602
|
// saves none once this machine removes it too (2026-10-24).
|
|
537
|
-
const changed =
|
|
603
|
+
const changed = [
|
|
604
|
+
...(await changedTracked(opts.root, base.commit, m, names(m.project, m.outputs))),
|
|
605
|
+
...strays,
|
|
606
|
+
]
|
|
538
607
|
const now = await stamps([...found.keys()])
|
|
539
608
|
const others = unchanged(found, now)
|
|
540
609
|
const removed = [...found.keys()]
|
|
@@ -553,6 +622,7 @@ export async function runWorker(opts: {
|
|
|
553
622
|
: m.runKey
|
|
554
623
|
await reply(m, key, others, {
|
|
555
624
|
...res,
|
|
625
|
+
...(served > 0 ? { cpuMs: (res.cpuMs ?? 0) + served } : {}),
|
|
556
626
|
output: output.chunks(),
|
|
557
627
|
violations: back,
|
|
558
628
|
...(cpuShare === undefined ? {} : { cpuShare }),
|
|
@@ -611,21 +681,53 @@ export async function runWorker(opts: {
|
|
|
611
681
|
ahead.set(m.task, [...(ahead.get(m.task) ?? []), ...m.deps])
|
|
612
682
|
} else if (m.type === 'cancel') {
|
|
613
683
|
running.get(m.task)?.abort()
|
|
684
|
+
} else if (m.type === 'unserve') {
|
|
685
|
+
// Not awaited: its grace must not hold the next order.
|
|
686
|
+
const task = m.task
|
|
687
|
+
void floor
|
|
688
|
+
?.stop(task)
|
|
689
|
+
.catch((err: unknown) =>
|
|
690
|
+
ui.print(
|
|
691
|
+
'err',
|
|
692
|
+
ui.status(
|
|
693
|
+
'warn',
|
|
694
|
+
task,
|
|
695
|
+
`server not stopped: ${err instanceof Error ? err.message : String(err)}`,
|
|
696
|
+
),
|
|
697
|
+
),
|
|
698
|
+
)
|
|
614
699
|
} else if (m.type === 'batch') {
|
|
615
700
|
// Every batch of a run names the same orchestrator root.
|
|
616
701
|
root = m.root
|
|
617
702
|
realRoot = m.realRoot
|
|
618
703
|
batch = m.n
|
|
619
704
|
if (m.sandboxes !== undefined) {
|
|
620
|
-
if (floor === undefined) floor =
|
|
705
|
+
if (floor === undefined) floor = newFloor(m.sandboxes)
|
|
621
706
|
else await floor.rearm(m.sandboxes)
|
|
622
707
|
}
|
|
623
708
|
for (const t of m.tasks) {
|
|
709
|
+
const sent = ahead.get(t.task)
|
|
710
|
+
ahead.delete(t.task)
|
|
711
|
+
// Learned in the order sent, a task run already too: the run counts them sent.
|
|
712
|
+
let missing: string | undefined
|
|
713
|
+
const deps = (sent === undefined ? t.deps : [...sent, ...t.deps]).map((d): DepOrder => {
|
|
714
|
+
if (typeof d !== 'string') {
|
|
715
|
+
known.set(d.task, d)
|
|
716
|
+
return d
|
|
717
|
+
}
|
|
718
|
+
const got = known.get(d)
|
|
719
|
+
if (got === undefined) missing ??= d
|
|
720
|
+
return got!
|
|
721
|
+
})
|
|
624
722
|
// One it runs already is not run twice.
|
|
625
723
|
if (running.has(t.task)) continue
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
724
|
+
if (missing !== undefined) {
|
|
725
|
+
const reason = `was sent ${missing} by name, never whole`
|
|
726
|
+
ui.print('err', ui.status('error', t.task, `could not run: ${reason}`))
|
|
727
|
+
await say({ type: 'fault', task: t.task, reason }).catch(unsent('fault'))
|
|
728
|
+
continue
|
|
729
|
+
}
|
|
730
|
+
start({ ...t, deps })
|
|
629
731
|
}
|
|
630
732
|
}
|
|
631
733
|
}
|
|
@@ -638,6 +740,8 @@ export async function runWorker(opts: {
|
|
|
638
740
|
process.off('SIGINT', onInt)
|
|
639
741
|
process.off('SIGTERM', onTerm)
|
|
640
742
|
trees.stop()
|
|
743
|
+
serverTrees.stop()
|
|
744
|
+
clearInterval(sampler)
|
|
641
745
|
leaving.abort()
|
|
642
746
|
for (const c of running.values()) c.abort()
|
|
643
747
|
await Promise.all(work)
|
|
@@ -694,6 +798,13 @@ function cpuTimes(): [number, number] {
|
|
|
694
798
|
return [total - idle, total]
|
|
695
799
|
}
|
|
696
800
|
|
|
801
|
+
function selfAvg(from: NodeJS.CpuUsage, since: number): { self?: number } {
|
|
802
|
+
const ms = performance.now() - since
|
|
803
|
+
if (ms <= 0) return {}
|
|
804
|
+
const u = process.cpuUsage(from)
|
|
805
|
+
return { self: Math.round(((u.user + u.system) / 1000 / ms) * 1000) / 1000 }
|
|
806
|
+
}
|
|
807
|
+
|
|
697
808
|
/**
|
|
698
809
|
* The share of a core each runnable thread gets now: the machine's cores
|
|
699
810
|
* over the threads Linux has runnable (`procs_running`, less the one
|
|
@@ -718,7 +829,9 @@ export function crowdShare(stat: string, cores: number): number | undefined {
|
|
|
718
829
|
* more than the threads that wait for a core: a GitHub macOS runner
|
|
719
830
|
* read 49 with two test shards on 3 cores and a core idle, and the run
|
|
720
831
|
* learned each shard needed the whole machine (main 8fa3c98). So a
|
|
721
|
-
*
|
|
832
|
+
* machine whose cores were not all busy since the last look is not
|
|
833
|
+
* crowded: Linux's run queue read a share of 0.67 on machines 83% busy,
|
|
834
|
+
* and the run booked tasks 1.5 times the CPU they used (2026-11-07).
|
|
722
835
|
*/
|
|
723
836
|
export function loadMeter(
|
|
724
837
|
read: () => string | undefined = procStat,
|
|
@@ -733,6 +846,8 @@ export function loadMeter(
|
|
|
733
846
|
} {
|
|
734
847
|
let last = times()
|
|
735
848
|
const first = last
|
|
849
|
+
const self = process.cpuUsage()
|
|
850
|
+
const since = performance.now()
|
|
736
851
|
let cpu = 0
|
|
737
852
|
const timer = setInterval(() => {
|
|
738
853
|
const next = times()
|
|
@@ -748,14 +863,16 @@ export function loadMeter(
|
|
|
748
863
|
const stat = read()
|
|
749
864
|
const avg = stat === undefined ? average() : 0
|
|
750
865
|
let full = true
|
|
751
|
-
if (stat
|
|
866
|
+
if (stat !== undefined || avg > 0) {
|
|
752
867
|
const now = times()
|
|
753
868
|
full = now[1] <= looked[1] || (now[0] - looked[0]) / (now[1] - looked[1]) >= FULL
|
|
754
869
|
looked = now
|
|
755
870
|
}
|
|
756
871
|
const share =
|
|
757
872
|
stat !== undefined
|
|
758
|
-
?
|
|
873
|
+
? full
|
|
874
|
+
? crowdShare(stat, cores)
|
|
875
|
+
: 1
|
|
759
876
|
: avg > 0
|
|
760
877
|
? full
|
|
761
878
|
? Math.min(1, cores / avg)
|
|
@@ -775,6 +892,7 @@ export function loadMeter(
|
|
|
775
892
|
cpu: Math.round(cpu * 100) / 100,
|
|
776
893
|
memory: os.totalmem() - available(),
|
|
777
894
|
...(total > 0 ? { avg: Math.round(((t[0] - first[0]) / total) * cores * 100) / 100 } : {}),
|
|
895
|
+
...selfAvg(self, since),
|
|
778
896
|
}
|
|
779
897
|
},
|
|
780
898
|
mark: () => ({ ...crowd, times: times() }),
|
|
@@ -818,6 +936,8 @@ export function treeMeter(
|
|
|
818
936
|
watch: (task: string, pid: number) => void
|
|
819
937
|
drop: (task: string) => void
|
|
820
938
|
now: () => number
|
|
939
|
+
/** One watched tree's cores over the last look. */
|
|
940
|
+
coresOf: (task: string) => number
|
|
821
941
|
stop: () => void
|
|
822
942
|
} {
|
|
823
943
|
const watched = new Map<string, { pid: number; at: number; cpuMs: number; cores: number }>()
|
|
@@ -865,6 +985,7 @@ export function treeMeter(
|
|
|
865
985
|
for (const w of watched.values()) cores += w.cores
|
|
866
986
|
return Math.round(cores * 100) / 100
|
|
867
987
|
},
|
|
988
|
+
coresOf: (task) => watched.get(task)?.cores ?? 0,
|
|
868
989
|
stop,
|
|
869
990
|
}
|
|
870
991
|
}
|
|
@@ -956,6 +1077,38 @@ export async function changedTracked(
|
|
|
956
1077
|
.filter((f) => f !== '' && !output(f) && !m.nested.some((n) => f.startsWith(`${n}/`)))
|
|
957
1078
|
}
|
|
958
1079
|
|
|
1080
|
+
/**
|
|
1081
|
+
* Untracked files git does not ignore under the task's project,
|
|
1082
|
+
* workspace-relative, those `held` matches (its outputs, its dependencies')
|
|
1083
|
+
* and nested projects' aside.
|
|
1084
|
+
*/
|
|
1085
|
+
async function untrackedUnder(
|
|
1086
|
+
root: string,
|
|
1087
|
+
m: RunOrder,
|
|
1088
|
+
held: readonly ((file: string) => boolean)[],
|
|
1089
|
+
): Promise<string[]> {
|
|
1090
|
+
const p = Bun.spawn(
|
|
1091
|
+
[
|
|
1092
|
+
'git',
|
|
1093
|
+
'ls-files',
|
|
1094
|
+
'--others',
|
|
1095
|
+
'--exclude-standard',
|
|
1096
|
+
'-z',
|
|
1097
|
+
'--',
|
|
1098
|
+
m.project === '' ? '.' : `:(literal)${m.project}`,
|
|
1099
|
+
],
|
|
1100
|
+
{ cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'ignore' },
|
|
1101
|
+
)
|
|
1102
|
+
const [out, exit] = await Promise.all([new Response(p.stdout).text(), p.exited])
|
|
1103
|
+
// A git that could not say is no proof there are none.
|
|
1104
|
+
if (exit !== 0) return [m.project === '' ? '.' : m.project]
|
|
1105
|
+
return out
|
|
1106
|
+
.split('\0')
|
|
1107
|
+
.filter(
|
|
1108
|
+
(f) => f !== '' && !held.some((h) => h(f)) && !m.nested.some((n) => f.startsWith(`${n}/`)),
|
|
1109
|
+
)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
959
1112
|
/** The checkout's tracked files, relative to `root`; none outside git. */
|
|
960
1113
|
export function trackedFiles(root: string): Set<string> {
|
|
961
1114
|
const r = Bun.spawnSync(['git', 'ls-files', '-z'], { cwd: root, stderr: 'pipe' })
|
|
@@ -985,23 +1138,25 @@ function available(): number {
|
|
|
985
1138
|
|
|
986
1139
|
/**
|
|
987
1140
|
* The orchestrator's env for a task, made this machine's: its checkout root
|
|
988
|
-
* swapped for ours, host names from our own env
|
|
989
|
-
* the workspace's bin directories.
|
|
1141
|
+
* swapped for ours, host names from our own env but those its key folds as
|
|
1142
|
+
* `unset`, and PATH our own behind the workspace's bin directories.
|
|
990
1143
|
*/
|
|
991
1144
|
export function localEnv(
|
|
992
1145
|
env: Readonly<Record<string, string>>,
|
|
993
1146
|
remoteRoot: string,
|
|
994
1147
|
root: string,
|
|
1148
|
+
unset: readonly string[] = [],
|
|
995
1149
|
): Record<string, string | undefined> {
|
|
996
1150
|
// By path: a plain replace moved `/ws2` and `/private/ws` with `/ws`.
|
|
997
1151
|
const out: Record<string, string | undefined> = swapRoots({ ...env }, [[remoteRoot, root]])
|
|
998
|
-
// Sent only when the task's `env.define` names it
|
|
1152
|
+
// Sent only when the task's `env.define` names it or its `inputs.env`
|
|
1153
|
+
// keys it: its value, as keyed.
|
|
999
1154
|
// Else this machine's manager, as core sets it on a local task.
|
|
1000
|
-
if (!('npm_execpath' in out)) {
|
|
1155
|
+
if (!('npm_execpath' in out) && !unset.includes('npm_execpath')) {
|
|
1001
1156
|
const manager = packageManagerPath(root)
|
|
1002
1157
|
if (manager !== null) out['npm_execpath'] = manager
|
|
1003
1158
|
}
|
|
1004
|
-
for (const k of HOST_ENV) if (!(k in out)) out[k] = process.env[k]
|
|
1159
|
+
for (const k of HOST_ENV) if (!(k in out) && !unset.includes(k)) out[k] = process.env[k]
|
|
1005
1160
|
const bins = (out['PATH'] ?? '').split(':').filter((p) => p === root || p.startsWith(`${root}/`))
|
|
1006
1161
|
out['PATH'] = [...bins, process.env['PATH'] ?? ''].join(':')
|
|
1007
1162
|
return out
|