@vzn/vx-dte 0.0.649 → 0.0.651
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 +42 -29
- package/package.json +3 -3
- package/src/github.ts +16 -5
- package/src/messages.ts +38 -27
- package/src/orchestrator.ts +138 -152
- package/src/plugin.ts +20 -12
- package/src/seal.ts +11 -4
- package/src/tree.ts +7 -1
- package/src/wire-host.ts +98 -0
- package/src/wire-thread.ts +168 -0
- package/src/wire.ts +87 -66
- package/src/worker.ts +86 -58
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ before sealing, since no relay or socket compresses what is sealed. It
|
|
|
88
88
|
is padded with trailing spaces to its size class, a power of two from
|
|
89
89
|
256 bytes, then whole transport messages, so the relay learns no size
|
|
90
90
|
within a class. Inside, each send is numbered and acknowledged
|
|
91
|
-
end to end, and a quiet
|
|
91
|
+
end to end, and a quiet worker reports (Switchboard § Delivery, § Liveness).
|
|
92
92
|
|
|
93
93
|
## Try it
|
|
94
94
|
|
|
@@ -204,7 +204,8 @@ every 30 s, asks with each answer's ETag (an unchanged answer is free),
|
|
|
204
204
|
and asks nothing until a spent limit resets. A dispatch the limit refuses
|
|
205
205
|
is sent again at the reset, if that comes within 5 minutes; past that it
|
|
206
206
|
fails, naming when. A cancel the limit refuses is sent again at the
|
|
207
|
-
reset, if that comes within a minute
|
|
207
|
+
reset, if that comes within a minute, unless a worker given up as late
|
|
208
|
+
joined meanwhile: the run took it, so its workflow run goes on. A run that dies before it cancels
|
|
208
209
|
(a cancelled or timed-out job) leaves it to the job's last step, which
|
|
209
210
|
cancels each worker run the run listed in `RUNNER_TEMP`. Past both, a
|
|
210
211
|
worker that starts reads its run's attempt (`parent`) first, and once
|
|
@@ -456,9 +457,11 @@ use your own instead.
|
|
|
456
457
|
- A worker reports the cores and memory its machine has when it joins.
|
|
457
458
|
One the run started is held to the size it was started as, so
|
|
458
459
|
`local()` workers sharing a host split it rather than each take all of
|
|
459
|
-
it. Each frame it sends (a task
|
|
460
|
-
|
|
461
|
-
|
|
460
|
+
it. Each frame it sends (a task saved, its report, a fault) carries its
|
|
461
|
+
machine's load: cores busy over the last second and memory in use. A
|
|
462
|
+
heartbeat with nothing else to say, it sends its report: that load, the
|
|
463
|
+
tasks it runs and the last batch it read. A task of that batch or before
|
|
464
|
+
its report no longer names goes back to the queue at once. A task goes to it only when it fits now: while
|
|
462
465
|
one of its cores is free and the task's memory fits. What a worker has
|
|
463
466
|
in use is what its running tasks used on their last run (history's
|
|
464
467
|
measured CPU and peak RAM), or its live load plus what was sent since
|
|
@@ -535,10 +538,10 @@ use your own instead.
|
|
|
535
538
|
leaves no file. One waits there up to 10 s; past that, with that worker
|
|
536
539
|
gone or of another platform, or with its uncached upstreams on two
|
|
537
540
|
workers, the upstream runs again where the dependant can go, and the
|
|
538
|
-
dependant follows it. A task whose upload no remote took
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
runs, its
|
|
541
|
+
dependant follows it. A task whose upload no remote took after three
|
|
542
|
+
tries (too big, a 429, a reset connection) is its worker's fault: that
|
|
543
|
+
worker takes no more tasks and the task runs on another; a second fault
|
|
544
|
+
fails the run. Before a task runs, its
|
|
542
545
|
worker removes the files under its outputs that an earlier task left,
|
|
543
546
|
except committed files, nested projects' files, and its dependencies'
|
|
544
547
|
outputs (an additive task builds on its upstream's tree). An upstream's
|
|
@@ -576,8 +579,8 @@ use your own instead.
|
|
|
576
579
|
`timeouts`).
|
|
577
580
|
- A worker that stops answering, stops saying it holds a task, or cannot
|
|
578
581
|
run one (a full disk) gets no more work, and its tasks run again on
|
|
579
|
-
another. A task two such workers
|
|
580
|
-
|
|
582
|
+
another. A task two such workers were sent fails, naming both; one whose
|
|
583
|
+
send failed does not count. With no worker of the run left
|
|
581
584
|
to take it and none starting, it fails at once.
|
|
582
585
|
- A relay that answers nothing for 10 s, every try counted, fails every task not done
|
|
583
586
|
(`the relay is unreachable`); none runs here, which may be far smaller
|
|
@@ -587,8 +590,8 @@ use your own instead.
|
|
|
587
590
|
- The relay stores nothing, so a restart forgets the run. The orchestrator
|
|
588
591
|
joins again as its host and each worker joins again as itself, for two
|
|
589
592
|
heartbeats (4 s on the default); tasks keep running meanwhile, and what
|
|
590
|
-
either side sent goes again once both are back, taken once.
|
|
591
|
-
|
|
593
|
+
either side sent goes again once both are back, taken once. A worker
|
|
594
|
+
keeps what it runs, and one sent a task it runs already
|
|
592
595
|
does not run it again, so no work repeats. One not back by then
|
|
593
596
|
ends: a worker's tasks run elsewhere, an orchestrator's run fails.
|
|
594
597
|
Joining has the same 10 s in all, its socket and request together: a
|
|
@@ -611,22 +614,32 @@ use your own instead.
|
|
|
611
614
|
orchestrator's at its run's end is the final call that closes the run,
|
|
612
615
|
and a worker's when it is done. Either whose socket drops without it
|
|
613
616
|
dials again, 3 times within 10 s, and the relay keeps it, and its run,
|
|
614
|
-
for those 10 s; past them the run is over.
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
617
|
+
for those 10 s; past them the run is over. Either side hearing nothing
|
|
618
|
+
from the other for 3 heartbeats (6 s on the default) loses it, and the
|
|
619
|
+
rest follows from that: a worker that said nothing for half of it (3 s)
|
|
620
|
+
sends its report; the orchestrator never speaks on a timer, only orders
|
|
621
|
+
and the acks of what it heard; a send unacknowledged for 3 s goes again.
|
|
622
|
+
A worker that loses its orchestrator ends, and says so. Nobody probes, the relay answers
|
|
623
|
+
neither, and nobody pings. An orchestrator
|
|
621
624
|
with no worker yet asks the relay an empty frame instead; a frame the
|
|
622
625
|
relay leaves unanswered a heartbeat closes the socket, which is dialed
|
|
623
626
|
again.
|
|
627
|
+
- The orchestrator's socket, its acks and redials run on a thread
|
|
628
|
+
of their own, so sync work on the run's loop never holds them (one CI
|
|
629
|
+
run went quiet 12 s that way and lost every worker). A loop held a
|
|
630
|
+
second or more says so, with how long; one held 5 heartbeats (10 s)
|
|
631
|
+
ends the run, as a dead orchestrator would.
|
|
624
632
|
- Each send is numbered for its receiver and held until the receiver
|
|
625
633
|
acknowledges it, so one a socket or the relay dropped goes again after
|
|
626
|
-
a heartbeat, or at once on a new socket; the receiver takes
|
|
627
|
-
in order. An ack rides the next send back
|
|
628
|
-
|
|
629
|
-
|
|
634
|
+
a heartbeat and a half, or at once on a new socket; the receiver takes
|
|
635
|
+
each once, in order. An ack rides the next send back; the orchestrator's,
|
|
636
|
+
with no order to ride, go together after half a heartbeat, except a
|
|
637
|
+
quiet worker's beat, acked at once.
|
|
638
|
+
- A task sent runs at once: no message says it began. A command that
|
|
639
|
+
ended (`ran`, with when it began and ended by the worker's clock) rides
|
|
640
|
+
the worker's next frame, never alone; a task that saves first says it
|
|
641
|
+
in its `end` (begun, ran, ended). The run reads those times against the
|
|
642
|
+
clock of the frame that carried them.
|
|
630
643
|
- A run where no task began or ended for a minute warns what it waits
|
|
631
644
|
on, a line a task: which worker, and whether it runs or saves.
|
|
632
645
|
- A message the relay would not take is a warning naming it, its
|
|
@@ -651,9 +664,9 @@ An idle worker the rest of the run cannot keep busy leaves (`left`): one
|
|
|
651
664
|
beyond the peak of the cores the remaining tasks keep busy, so a lull
|
|
652
665
|
between two wide parts keeps it. One a remaining task needs for its size
|
|
653
666
|
stays, and so does one a later task needs when its size's `max` is
|
|
654
|
-
spent, since none could start in its place. A lost worker's
|
|
667
|
+
spent, since none could start in its place. A lost worker's tasks run again on
|
|
655
668
|
another, and the run starts one in its place. A worker is lost when its
|
|
656
|
-
socket closes, when it
|
|
669
|
+
socket closes, when it says nothing for 3 heartbeats (6 s), or at
|
|
657
670
|
once when its adapter sees its process or job end. Workers start at the first
|
|
658
671
|
task the cache does not answer, so a warm run starts none. Once one has
|
|
659
672
|
joined, its warm-up is what the next of its size takes at the least: work
|
|
@@ -670,10 +683,10 @@ run is over, one still starting is `not joined`), the tasks it
|
|
|
670
683
|
has done, failed or had aborted by a stop, and the ones it runs now; while the run goes, those that left
|
|
671
684
|
are one line (`3 workers left 9 done`). A worker the
|
|
672
685
|
run started is named by its size and the order it was started in; one
|
|
673
|
-
that joined on its own is `worker#N`. A task
|
|
674
|
-
|
|
686
|
+
that joined on its own is `worker#N`. A task waiting for a worker has no
|
|
687
|
+
row; the tasks line counts it `scheduled`
|
|
675
688
|
(`30 success · 4 running · 12 scheduled · 62 total`). Its row and clock
|
|
676
|
-
start when
|
|
689
|
+
start when it is sent: a worker runs what it is sent at once. A worker is `online` while the run
|
|
677
690
|
goes; it ends `left` when the run let it go as not needed, `lost` when
|
|
678
691
|
it crashed or went silent, and `stopped` when the run's end stopped it.
|
|
679
692
|
Once the run is over, each worker adds its warm-up (from asking the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-dte",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.651",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Distributed task execution for vx: one run spread over workers your own CI spawns, joined through a relay.",
|
|
6
6
|
"keywords": [
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"bun": ">=1.4.4"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@vzn/vx-dte-server": "0.0.
|
|
32
|
+
"@vzn/vx-dte-server": "0.0.651"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@vzn/vx": "^0.0.
|
|
35
|
+
"@vzn/vx": "^0.0.651"
|
|
36
36
|
},
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
package/src/github.ts
CHANGED
|
@@ -138,9 +138,10 @@ export function github(options: GithubOptions = {}): DteAdapter {
|
|
|
138
138
|
* else is said. One the spent API limit refused is asked again at its
|
|
139
139
|
* reset, when that comes within `CANCEL_WAIT_MS`.
|
|
140
140
|
*/
|
|
141
|
-
const cancelOf = async (run: string): Promise<void> => {
|
|
141
|
+
const cancelOf = async (run: string, still: () => boolean = () => true): Promise<void> => {
|
|
142
142
|
const end = Date.now() + CANCEL_WAIT_MS
|
|
143
143
|
for (;;) {
|
|
144
|
+
if (!still()) return
|
|
144
145
|
const res = await call(`${run}/cancel`, { method: 'POST' }).catch((err: unknown) => {
|
|
145
146
|
failed(`cancelling ${runOf(run)}`, err instanceof Error ? err.message : String(err))
|
|
146
147
|
return undefined
|
|
@@ -227,7 +228,9 @@ export function github(options: GithubOptions = {}): DteAdapter {
|
|
|
227
228
|
return
|
|
228
229
|
}
|
|
229
230
|
for (const id of ids) decide(id, false)
|
|
230
|
-
|
|
231
|
+
// Asked again at an API limit's reset, it stops once the run took
|
|
232
|
+
// one of them after all (`kept`).
|
|
233
|
+
return cancelOf(run, () => ids.every((id) => cancelled.has(id)))
|
|
231
234
|
})
|
|
232
235
|
.finally(() => pending.delete(p))
|
|
233
236
|
pending.add(p)
|
|
@@ -373,10 +376,13 @@ export function github(options: GithubOptions = {}): DteAdapter {
|
|
|
373
376
|
throw new Error(`github(): dispatching ${workflow}: ${res.status} ${text}${fix}`)
|
|
374
377
|
}
|
|
375
378
|
}
|
|
376
|
-
|
|
379
|
+
// quiet: a body that is not JSON names no run, refused below
|
|
380
|
+
const said =
|
|
377
381
|
res.status === 200
|
|
378
|
-
? ((await res.json()) as { workflow_run_id?:
|
|
382
|
+
? ((await res.json().catch(() => null)) as { workflow_run_id?: unknown } | null)
|
|
383
|
+
?.workflow_run_id
|
|
379
384
|
: undefined
|
|
385
|
+
const id = Number.isSafeInteger(said) && (said as number) > 0 ? (said as number) : undefined
|
|
380
386
|
// An API naming no run (a 204) is refused, not trusted by repository
|
|
381
387
|
// alone: any job of it with id-token: write would join as a worker.
|
|
382
388
|
if (id === undefined) {
|
|
@@ -472,6 +478,11 @@ export function github(options: GithubOptions = {}): DteAdapter {
|
|
|
472
478
|
for (const id of gone) decide(id, false)
|
|
473
479
|
return Promise.all(left).then((l) => ids.filter((_, i) => l[i]))
|
|
474
480
|
},
|
|
481
|
+
// One given up as late joined after all: its run is not cancelled.
|
|
482
|
+
kept(id) {
|
|
483
|
+
late.delete(id)
|
|
484
|
+
cancelled.delete(id)
|
|
485
|
+
},
|
|
475
486
|
// A finished run answers 409; one still queued or running is the
|
|
476
487
|
// run's no more: its results are in, or the run failed without them.
|
|
477
488
|
async close() {
|
|
@@ -481,7 +492,7 @@ export function github(options: GithubOptions = {}): DteAdapter {
|
|
|
481
492
|
await Promise.all(pending)
|
|
482
493
|
for (const id of [...deciding.keys()]) decide(id, false)
|
|
483
494
|
// A cancel that fails leaves the run to its timeout, and says so.
|
|
484
|
-
await Promise.all(runs.splice(0).map(cancelOf))
|
|
495
|
+
await Promise.all(runs.splice(0).map((run) => cancelOf(run)))
|
|
485
496
|
},
|
|
486
497
|
}
|
|
487
498
|
}
|
package/src/messages.ts
CHANGED
|
@@ -2,15 +2,7 @@
|
|
|
2
2
|
// (the relay never reads it): orders and status only. Every task's files
|
|
3
3
|
// and output go through the workspace's cache, by the keys these name.
|
|
4
4
|
|
|
5
|
-
import type { CacheFacts,
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* A worker says what it holds every this many heartbeats, and a task's
|
|
9
|
-
* lease is three of them. Every heartbeat (2026-10-25: the fastest, not
|
|
10
|
-
* a free plan's message count): a task its worker dropped while alive
|
|
11
|
-
* goes back in 15 s, not 90.
|
|
12
|
-
*/
|
|
13
|
-
export const HELD_EVERY = 1
|
|
5
|
+
import type { CacheFacts, ExecuteResult, SandboxConfig } from '@vzn/vx'
|
|
14
6
|
|
|
15
7
|
/** A worker's `join` info. */
|
|
16
8
|
export interface WorkerInfo {
|
|
@@ -100,9 +92,13 @@ export interface ServerOrder {
|
|
|
100
92
|
}
|
|
101
93
|
|
|
102
94
|
export type ToWorker =
|
|
103
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* Tasks that fit what it has free: taken, each runs at once. `n` counts
|
|
97
|
+
* its batches, so its report says which it had read.
|
|
98
|
+
*/
|
|
104
99
|
| {
|
|
105
100
|
type: 'batch'
|
|
101
|
+
n: number
|
|
106
102
|
root: string
|
|
107
103
|
/** `root`'s real path, as sandbox paths spell it. */
|
|
108
104
|
realRoot: string
|
|
@@ -146,26 +142,48 @@ export interface Load {
|
|
|
146
142
|
* `cpu` (one that sent 3, each as a task started, read 0.6 of 2.0 used).
|
|
147
143
|
*/
|
|
148
144
|
readonly avg?: number
|
|
145
|
+
/** When it was read, by the worker's clock: its task times are read against it. */
|
|
146
|
+
readonly at?: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A task's times on its worker, epoch ms by the worker's clock: begun, command ended. */
|
|
150
|
+
export interface Ran {
|
|
151
|
+
readonly startedAt: number
|
|
152
|
+
readonly ranAt: number
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
export type ToOrchestrator =
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Its command is done; its outputs save now, its cores are free. Never
|
|
158
|
+
* sent alone: it rides the next message, or its `end` says it.
|
|
159
|
+
*/
|
|
160
|
+
| ({ type: 'ran'; task: string; load?: Load } & Ran)
|
|
161
|
+
/**
|
|
162
|
+
* A heartbeat with nothing else said: what it runs, the last batch it
|
|
163
|
+
* read, its live load. A task of that batch or before it no longer
|
|
164
|
+
* names goes back.
|
|
165
|
+
*/
|
|
166
|
+
| { type: 'report'; running: string[]; batch: number; load: Load }
|
|
167
|
+
/** Messages said at one moment, as one frame: waiting `ran`s and the event they ride. */
|
|
168
|
+
| { type: 'together'; messages: ToOrchestrator[] }
|
|
158
169
|
/** It could not run a task it began, not a non-zero exit: a full disk, a failed unpack. */
|
|
159
|
-
| { type: 'fault'; task: string; reason: string }
|
|
170
|
+
| { type: 'fault'; task: string; reason: string; load?: Load }
|
|
160
171
|
/** It ran the task and saved it under `key`; its output is in that entry. */
|
|
161
|
-
| {
|
|
172
|
+
| ({
|
|
173
|
+
type: 'end'
|
|
174
|
+
task: string
|
|
175
|
+
key: string
|
|
176
|
+
result: WiredResult
|
|
177
|
+
endedAt: number
|
|
178
|
+
load?: Load
|
|
179
|
+
} & Ran)
|
|
162
180
|
/**
|
|
163
181
|
* Ahead of an `end` too long for one relay frame, some of its lists:
|
|
164
182
|
* the `end` that follows adds its own to them. One `end` with a sandboxed
|
|
165
183
|
* task's 9000 violations was over the relay's 1 MiB, and the run waited
|
|
166
184
|
* for it (2026-10-25).
|
|
167
185
|
*/
|
|
168
|
-
| ({ type: 'part'; task: string } & Partial<Pick<WiredResult, PartList>>)
|
|
186
|
+
| ({ type: 'part'; task: string; load?: Load } & Partial<Pick<WiredResult, PartList>>)
|
|
169
187
|
|
|
170
188
|
/**
|
|
171
189
|
* A message's JSON in UTF-8 bytes, what a relay frame holds: counted in
|
|
@@ -175,7 +193,7 @@ export type ToOrchestrator =
|
|
|
175
193
|
export const bytesOf = (body: unknown): number => Buffer.byteLength(JSON.stringify(body))
|
|
176
194
|
|
|
177
195
|
/** The lists a long result sends ahead of its `end`, in parts. */
|
|
178
|
-
export const PART_LISTS = ['violations', 'changed', 'removed'
|
|
196
|
+
export const PART_LISTS = ['violations', 'changed', 'removed'] as const
|
|
179
197
|
export type PartList = (typeof PART_LISTS)[number]
|
|
180
198
|
|
|
181
199
|
/** A result as JSON carries it: its output stays in the cache entry, and a violation's time is a string. */
|
|
@@ -187,13 +205,6 @@ export type WiredResult = Omit<ExecuteResult, 'violations' | 'outputs' | 'stdout
|
|
|
187
205
|
removed?: string[]
|
|
188
206
|
/** Dependency keys whose remote copy did not read back: this machine sends its own over them. */
|
|
189
207
|
unread?: string[]
|
|
190
|
-
/**
|
|
191
|
-
* No remote took its entry's upload (too big, a 429): its files are on
|
|
192
|
-
* that worker alone, and its output comes here in `output`.
|
|
193
|
-
*/
|
|
194
|
-
unshared?: true
|
|
195
|
-
/** Its output, both streams in the order written, when `unshared`. */
|
|
196
|
-
output?: CapturedChunk[]
|
|
197
208
|
}
|
|
198
209
|
|
|
199
210
|
/**
|