@vzn/vx-dte 0.0.650 → 0.0.652
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 +75 -31
- package/package.json +3 -3
- package/src/dispatch.ts +501 -26
- package/src/forecast.ts +1 -2
- package/src/github.ts +16 -5
- package/src/messages.ts +52 -31
- package/src/orchestrator.ts +551 -352
- package/src/planner.ts +1322 -172
- package/src/plugin.ts +48 -8
- package/src/seal.ts +11 -4
- package/src/wire-host.ts +98 -0
- package/src/wire-thread.ts +168 -0
- package/src/wire.ts +117 -68
- package/src/worker.ts +112 -62
package/src/wire.ts
CHANGED
|
@@ -42,18 +42,24 @@ export function unsent(
|
|
|
42
42
|
*/
|
|
43
43
|
const FIRST_REDIAL_MS = 250
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
45
|
+
* A peer quiet this many heartbeats is lost (6 s on the default): the one
|
|
46
|
+
* number the others derive from.
|
|
47
47
|
*/
|
|
48
|
-
const
|
|
49
|
-
/** A worker probes its orchestrator quiet this long: after the orchestrator would have. */
|
|
50
|
-
const WORKER_PROBE_BEATS = 1.6
|
|
48
|
+
const LOST_BEATS = 3
|
|
51
49
|
/**
|
|
52
|
-
* A
|
|
53
|
-
*
|
|
54
|
-
* (4 s on the default).
|
|
50
|
+
* A worker quiet half of `LOST_BEATS` sends its report (3 s), and its
|
|
51
|
+
* orchestrator's ack answers it: one late frame is not a loss.
|
|
55
52
|
*/
|
|
56
|
-
const
|
|
53
|
+
const QUIET_BEATS = LOST_BEATS / 2
|
|
54
|
+
/**
|
|
55
|
+
* How long the orchestrator gives its own reaction to a frame (a pump, a
|
|
56
|
+
* batch) to carry the frame's ack, before the ack goes alone.
|
|
57
|
+
*/
|
|
58
|
+
const REACT_MS = 20
|
|
59
|
+
/** A send unacknowledged as long goes again: its ack comes within it. */
|
|
60
|
+
const RESEND_BEATS = LOST_BEATS / 2
|
|
61
|
+
/** Why a worker ends on its orchestrator's silence. */
|
|
62
|
+
export const ORCHESTRATOR_SILENT = 'relay: the orchestrator went silent'
|
|
57
63
|
/**
|
|
58
64
|
* A relay that forgot this participant (a restart: it stores nothing) is
|
|
59
65
|
* joined again for this many heartbeats; the orchestrator joins as the
|
|
@@ -61,8 +67,8 @@ const LOST_BEATS = 1.9
|
|
|
61
67
|
* rejoin for 2 heartbeats, it's over").
|
|
62
68
|
*/
|
|
63
69
|
const REJOIN_BEATS = 2
|
|
64
|
-
/** What a
|
|
65
|
-
const
|
|
70
|
+
/** What a beat delivers: nothing. */
|
|
71
|
+
const BEAT = Symbol('beat')
|
|
66
72
|
/** A frame of several sends stays under this, half the relay's 1 MiB body limit. */
|
|
67
73
|
const FRAME_BYTES = 512 * 1024
|
|
68
74
|
/** How long `leave` waits on its socket's close handshake. */
|
|
@@ -121,17 +127,17 @@ const isNumbered = (b: unknown): b is Numbered =>
|
|
|
121
127
|
'body' in b &&
|
|
122
128
|
Object.keys(b).length === ('acked' in b ? 3 : 2)
|
|
123
129
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
130
|
+
* What a side with nothing else to say a heartbeat sends: a numbered send
|
|
131
|
+
* with no body, carrying its acks, delivered to no one.
|
|
126
132
|
*/
|
|
127
|
-
interface
|
|
133
|
+
interface Beat {
|
|
128
134
|
readonly seq: number
|
|
129
135
|
readonly acked?: number
|
|
130
136
|
}
|
|
131
|
-
const
|
|
137
|
+
const isBeat = (b: unknown): b is Beat =>
|
|
132
138
|
typeof b === 'object' &&
|
|
133
139
|
b !== null &&
|
|
134
|
-
typeof (b as
|
|
140
|
+
typeof (b as Beat).seq === 'number' &&
|
|
135
141
|
!('body' in b) &&
|
|
136
142
|
Object.keys(b).length === ('acked' in b ? 2 : 1)
|
|
137
143
|
const isAcked = (b: unknown): b is Acked =>
|
|
@@ -169,6 +175,12 @@ export interface Participant {
|
|
|
169
175
|
* said meanwhile, and is not lost for that.
|
|
170
176
|
*/
|
|
171
177
|
resumed(f: (peer?: string) => void): void
|
|
178
|
+
/**
|
|
179
|
+
* A worker's `f` is told when it said nothing to its orchestrator a
|
|
180
|
+
* heartbeat: it says something now, its live report. Untold, the wire
|
|
181
|
+
* sends a beat. The orchestrator is never told: it speaks on no timer.
|
|
182
|
+
*/
|
|
183
|
+
quiet(f: (peer: string) => void): void
|
|
172
184
|
send(to: string, body: unknown): Promise<void>
|
|
173
185
|
/** Held up to one heartbeat, which also keeps this participant alive. */
|
|
174
186
|
poll(signal?: AbortSignal): Promise<RelayMessage[]>
|
|
@@ -255,45 +267,70 @@ export async function join(
|
|
|
255
267
|
>()
|
|
256
268
|
/** Each sender's sends taken in order, and those that came ahead of a gap. */
|
|
257
269
|
const taken = new Map<string, { upTo: number; ahead: Map<number, unknown> }>()
|
|
270
|
+
/** Acks owed, by peer: each rides the next send to its peer. */
|
|
271
|
+
const owing = new Set<string>()
|
|
258
272
|
/**
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
273
|
+
* The orchestrator sends only orders and acks: an ack no order carried
|
|
274
|
+
* goes on its own after half a heartbeat, every peer's in one frame. A
|
|
275
|
+
* worker's ride its next send, which it makes within a heartbeat. A peer
|
|
276
|
+
* the orchestrator said nothing to in a heartbeat (a quiet worker's
|
|
277
|
+
* report) is answered once the orchestrator reacted: an order sent in
|
|
278
|
+
* reply carries the ack, and with none it goes alone, `REACT_MS` on.
|
|
262
279
|
*/
|
|
263
|
-
const owing = new Set<string>()
|
|
264
280
|
let acking: ReturnType<typeof setTimeout> | undefined
|
|
281
|
+
let answering: ReturnType<typeof setTimeout> | undefined
|
|
282
|
+
const soon = new Set<string>()
|
|
283
|
+
const ack = (peer: string): void => {
|
|
284
|
+
const upTo = taken.get(peer)?.upTo
|
|
285
|
+
owing.delete(peer)
|
|
286
|
+
said.set(peer, performance.now())
|
|
287
|
+
// quiet: a lost ack only costs a resend, which is dropped as seen
|
|
288
|
+
if (upTo !== undefined) put(peer, { acked: upTo } satisfies Acked).catch(() => undefined)
|
|
289
|
+
}
|
|
265
290
|
const flushAcks = (): void => {
|
|
266
|
-
clearTimeout(acking)
|
|
267
291
|
acking = undefined
|
|
268
292
|
if (left || ended !== undefined) return
|
|
269
|
-
for (const peer of owing)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
293
|
+
for (const peer of [...owing]) ack(peer)
|
|
294
|
+
}
|
|
295
|
+
const answer = (): void => {
|
|
296
|
+
answering = undefined
|
|
297
|
+
const peers = [...soon]
|
|
298
|
+
soon.clear()
|
|
299
|
+
if (left || ended !== undefined) return
|
|
300
|
+
for (const peer of peers) if (owing.has(peer)) ack(peer)
|
|
276
301
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (now)
|
|
302
|
+
const owe = (peer: string): void => {
|
|
303
|
+
owing.add(peer)
|
|
304
|
+
if (role !== 'orchestrator') return
|
|
305
|
+
if (performance.now() - (said.get(peer) ?? 0) >= heartbeatMs) {
|
|
306
|
+
soon.add(peer)
|
|
307
|
+
if (answering === undefined) {
|
|
308
|
+
answering = setTimeout(answer, REACT_MS)
|
|
309
|
+
answering.unref()
|
|
310
|
+
}
|
|
311
|
+
return
|
|
312
|
+
}
|
|
281
313
|
if (acking !== undefined) return
|
|
282
314
|
acking = setTimeout(flushAcks, heartbeatMs / 2)
|
|
283
315
|
acking.unref()
|
|
284
316
|
}
|
|
285
|
-
/**
|
|
286
|
-
const
|
|
287
|
-
|
|
317
|
+
/** When this side last sent each peer anything: a worker quiet a heartbeat says something. */
|
|
318
|
+
const said = new Map<string, number>()
|
|
319
|
+
let onQuiet: ((peer: string) => void) | undefined
|
|
320
|
+
/** The ack owed `to`, if any, for a send to it to carry; and `to` was just said to. */
|
|
321
|
+
const owed = (to: string): { acked?: number } => {
|
|
322
|
+
said.set(to, performance.now())
|
|
323
|
+
return owing.delete(to) ? { acked: taken.get(to)!.upTo } : {}
|
|
324
|
+
}
|
|
288
325
|
/**
|
|
289
326
|
* When each peer was last heard from: the orchestrator's workers from
|
|
290
327
|
* their `joined`, a worker's orchestrator from its join. One quiet
|
|
291
|
-
* `
|
|
328
|
+
* `LOST_BEATS` is lost
|
|
292
329
|
* (2026-10-23: "if it does not reply for 10s its dead"), on a socket that
|
|
293
330
|
* never closed: the relay loses one whose socket closed and says so.
|
|
294
331
|
*/
|
|
295
332
|
const heard = new Map<string, number>()
|
|
296
|
-
/** `quiet`:
|
|
333
|
+
/** `quiet`: a beat or a resend, which waits on no answer: the relay says only a failure. */
|
|
297
334
|
const put = (to: string, frame: object, quiet = true): Promise<void> =>
|
|
298
335
|
over((s) => s.send(relayIds.get(to) ?? to, frame, quiet))
|
|
299
336
|
const settle = (from: string, acked: number): void => {
|
|
@@ -327,17 +364,23 @@ export async function join(
|
|
|
327
364
|
const from = heldIds.get(m.from) ?? m.from
|
|
328
365
|
if (heard.has(from)) heard.set(from, performance.now())
|
|
329
366
|
if (isAcked(body)) settle(from, body.acked)
|
|
330
|
-
else if (
|
|
367
|
+
else if (isBeat(body)) {
|
|
331
368
|
if (body.acked !== undefined) settle(from, body.acked)
|
|
332
369
|
const t = taken.get(from) ?? { upTo: 0, ahead: new Map() }
|
|
333
370
|
taken.set(from, t)
|
|
334
|
-
if (body.seq > t.upTo) t.ahead.set(body.seq,
|
|
371
|
+
if (body.seq > t.upTo) t.ahead.set(body.seq, BEAT)
|
|
335
372
|
while (t.ahead.has(t.upTo + 1)) {
|
|
336
373
|
const next = t.ahead.get(++t.upTo)
|
|
337
374
|
t.ahead.delete(t.upTo)
|
|
338
|
-
if (next !==
|
|
375
|
+
if (next !== BEAT) out.push({ from, body: next })
|
|
339
376
|
}
|
|
340
|
-
|
|
377
|
+
// A beat comes only from a worker quiet `QUIET_BEATS`: half a
|
|
378
|
+
// heartbeat more would leave it 2 s of `LOST_BEATS` for a stall.
|
|
379
|
+
if (role === 'orchestrator') {
|
|
380
|
+
owing.delete(from)
|
|
381
|
+
// quiet: a lost ack only costs a resend, which is dropped as seen
|
|
382
|
+
put(from, { acked: t.upTo } satisfies Acked).catch(() => undefined)
|
|
383
|
+
} else owe(from)
|
|
341
384
|
} else if (isNumbered(body)) {
|
|
342
385
|
if (body.acked !== undefined) settle(from, body.acked)
|
|
343
386
|
let t = taken.get(from)
|
|
@@ -346,9 +389,9 @@ export async function join(
|
|
|
346
389
|
while (t.ahead.has(t.upTo + 1)) {
|
|
347
390
|
const next = t.ahead.get(++t.upTo)
|
|
348
391
|
t.ahead.delete(t.upTo)
|
|
349
|
-
if (next !==
|
|
392
|
+
if (next !== BEAT) out.push({ from, body: next })
|
|
350
393
|
}
|
|
351
|
-
|
|
394
|
+
owe(from)
|
|
352
395
|
} else out.push({ from, body })
|
|
353
396
|
}
|
|
354
397
|
return out
|
|
@@ -377,46 +420,49 @@ export async function join(
|
|
|
377
420
|
outbox.delete(peer)
|
|
378
421
|
taken.delete(peer)
|
|
379
422
|
owing.delete(peer)
|
|
423
|
+
said.delete(peer)
|
|
380
424
|
const now = relayIds.get(peer)
|
|
381
425
|
relayIds.delete(peer)
|
|
382
426
|
if (now !== undefined) heldIds.delete(now)
|
|
383
427
|
}
|
|
384
428
|
/**
|
|
385
|
-
*
|
|
386
|
-
* `
|
|
387
|
-
*
|
|
388
|
-
* one probe and its ack. Either loses a peer quiet `LOST_BEATS`.
|
|
429
|
+
* A worker says something to its orchestrator whenever it said nothing
|
|
430
|
+
* `QUIET_BEATS`: its live report (`quiet`), or a beat. The orchestrator
|
|
431
|
+
* answers with orders and acks only. Either loses a peer quiet `LOST_BEATS`.
|
|
389
432
|
*/
|
|
390
433
|
const listen = (): void => {
|
|
391
434
|
// With no socket nobody is heard: the redial or rejoin decides.
|
|
392
435
|
if (socket?.open !== true) return
|
|
393
|
-
resend(heartbeatMs)
|
|
436
|
+
resend(RESEND_BEATS * heartbeatMs)
|
|
394
437
|
const now = performance.now()
|
|
395
438
|
// With no peer, the relay is asked instead: a frame it answers.
|
|
396
439
|
if (heard.size === 0 && socket?.open === true && now - socket.heardAt >= heartbeatMs)
|
|
397
440
|
socket.ask()
|
|
398
441
|
for (const [peer, at] of heard) {
|
|
399
|
-
|
|
400
|
-
if (quiet >= LOST_BEATS * heartbeatMs) {
|
|
442
|
+
if (now - at >= LOST_BEATS * heartbeatMs) {
|
|
401
443
|
forget(peer)
|
|
402
444
|
if (peer === 'orchestrator') {
|
|
403
|
-
end(new RelayError(410,
|
|
445
|
+
end(new RelayError(410, ORCHESTRATOR_SILENT))
|
|
404
446
|
return
|
|
405
447
|
}
|
|
406
448
|
inbox.push({ from: 'relay', body: { type: 'lost', id: peer } })
|
|
407
449
|
nudge()
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
)
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
// quiet: unacknowledged, it goes again
|
|
418
|
-
put(peer, { seq, ...owed(peer) } satisfies Probe).catch(() => undefined)
|
|
450
|
+
continue
|
|
451
|
+
}
|
|
452
|
+
// The orchestrator never speaks on a timer: its workers do.
|
|
453
|
+
if (role === 'orchestrator') continue
|
|
454
|
+
if (now - (said.get(peer) ?? 0) < QUIET_BEATS * heartbeatMs) continue
|
|
455
|
+
said.set(peer, now)
|
|
456
|
+
if (onQuiet !== undefined) {
|
|
457
|
+
onQuiet(peer)
|
|
458
|
+
continue
|
|
419
459
|
}
|
|
460
|
+
let o = outbox.get(peer)
|
|
461
|
+
if (o === undefined) outbox.set(peer, (o = { next: 0, unacked: new Map() }))
|
|
462
|
+
const seq = ++o.next
|
|
463
|
+
o.unacked.set(seq, { body: BEAT, at: now })
|
|
464
|
+
// quiet: unacknowledged, it goes again
|
|
465
|
+
put(peer, { seq, ...owed(peer) } satisfies Beat).catch(() => undefined)
|
|
420
466
|
}
|
|
421
467
|
}
|
|
422
468
|
/** Sends again what waited `ms` unacknowledged, to `only` or to all; all of it, at 0. */
|
|
@@ -428,7 +474,7 @@ export async function join(
|
|
|
428
474
|
if (now - u.at < ms) continue
|
|
429
475
|
u.at = now
|
|
430
476
|
// quiet: unacknowledged, it goes again next heartbeat
|
|
431
|
-
const again = u.body ===
|
|
477
|
+
const again = u.body === BEAT ? { seq } : { seq, body: u.body }
|
|
432
478
|
put(to, { ...again, ...owed(to) }).catch(() => undefined)
|
|
433
479
|
}
|
|
434
480
|
}
|
|
@@ -582,8 +628,8 @@ export async function join(
|
|
|
582
628
|
else again()
|
|
583
629
|
}
|
|
584
630
|
// Every tenth of a heartbeat, so a peer quiet `LOST_BEATS` is lost within
|
|
585
|
-
// two heartbeats (4 s on the default) of its last word, and
|
|
586
|
-
//
|
|
631
|
+
// two heartbeats (4 s on the default) of its last word, and a quiet
|
|
632
|
+
// worker speaks within a tenth of its heartbeat.
|
|
587
633
|
const listening = setInterval(listen, heartbeatMs / 10)
|
|
588
634
|
listening.unref()
|
|
589
635
|
if (role === 'worker') heard.set('orchestrator', performance.now())
|
|
@@ -637,6 +683,9 @@ export async function join(
|
|
|
637
683
|
resumed(f) {
|
|
638
684
|
onResumed = f
|
|
639
685
|
},
|
|
686
|
+
quiet(f) {
|
|
687
|
+
onQuiet = f
|
|
688
|
+
},
|
|
640
689
|
async send(to, body) {
|
|
641
690
|
let o = outbox.get(to)
|
|
642
691
|
if (o === undefined) outbox.set(to, (o = { next: 0, unacked: new Map() }))
|
|
@@ -666,7 +715,7 @@ export async function join(
|
|
|
666
715
|
// One neither the relay nor this side knows waits on nothing, and
|
|
667
716
|
// would be asked every heartbeat for the rest of the run.
|
|
668
717
|
if (err.status === 404 && !heard.has(to)) outbox.delete(to)
|
|
669
|
-
else o.unacked.set(seq, { body:
|
|
718
|
+
else o.unacked.set(seq, { body: BEAT, at: 0 })
|
|
670
719
|
throw err
|
|
671
720
|
}
|
|
672
721
|
},
|
|
@@ -702,7 +751,7 @@ export async function join(
|
|
|
702
751
|
await said.catch(() => undefined)
|
|
703
752
|
}
|
|
704
753
|
left = true
|
|
705
|
-
// Ends what still waits on a socket (
|
|
754
|
+
// Ends what still waits on a socket (a send, a beat): no redial
|
|
706
755
|
// would ever bring one, and each waits a heartbeat at a time, for good.
|
|
707
756
|
end(new RelayError(410, 'relay: this participant left'))
|
|
708
757
|
const s = socket
|
|
@@ -984,7 +1033,7 @@ async function connect(
|
|
|
984
1033
|
return
|
|
985
1034
|
}
|
|
986
1035
|
if (m.n === undefined || m.n === null) {
|
|
987
|
-
// An unnumbered frame's failure:
|
|
1036
|
+
// An unnumbered frame's failure: a beat or resend to one the relay no
|
|
988
1037
|
// longer knows (404) is not one, its loss reaching this side apart.
|
|
989
1038
|
const failed = [m, ...(m.results ?? [])].filter(
|
|
990
1039
|
(a): a is Answer => a?.status !== undefined && a.status !== 404,
|
package/src/worker.ts
CHANGED
|
@@ -33,10 +33,10 @@ import {
|
|
|
33
33
|
import { childrenCpuMs, describeCpu, readPs, topBetween } from './cpu-account.js'
|
|
34
34
|
import {
|
|
35
35
|
bytesOf,
|
|
36
|
-
HELD_EVERY,
|
|
37
36
|
HOST_ENV,
|
|
38
37
|
PART_LISTS,
|
|
39
38
|
type DepOrder,
|
|
39
|
+
type WiredDep,
|
|
40
40
|
type Load,
|
|
41
41
|
type WorkerInfo,
|
|
42
42
|
type RunOrder,
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
import { leftUnder, names, outputFiles } from './outputs.js'
|
|
48
48
|
import { snapshot } from './tree.js'
|
|
49
49
|
import { sealWorker, type Proof } from './seal.js'
|
|
50
|
-
import { join, RelayError, unsent as unsentBy } from './wire.js'
|
|
50
|
+
import { join, ORCHESTRATOR_SILENT, RelayError, unsent as unsentBy } from './wire.js'
|
|
51
51
|
|
|
52
52
|
export async function runWorker(opts: {
|
|
53
53
|
relay: string
|
|
@@ -115,7 +115,9 @@ export async function runWorker(opts: {
|
|
|
115
115
|
const artifacts = opts.artifacts(() => facts)
|
|
116
116
|
const running = new Map<string, AbortController>()
|
|
117
117
|
/** Each order's deps sent ahead of its batch, until the batch comes. */
|
|
118
|
-
const ahead = new Map<string,
|
|
118
|
+
const ahead = new Map<string, WiredDep[]>()
|
|
119
|
+
/** Each dependency sent whole, by its task: an order after names it alone (`WiredDep`). */
|
|
120
|
+
const known = new Map<string, DepOrder>()
|
|
119
121
|
const work = new Set<Promise<void>>()
|
|
120
122
|
let root = opts.root
|
|
121
123
|
let realRoot = opts.root
|
|
@@ -152,7 +154,27 @@ export async function runWorker(opts: {
|
|
|
152
154
|
const onTerm = onSignal('SIGTERM')
|
|
153
155
|
process.once('SIGINT', onInt)
|
|
154
156
|
process.once('SIGTERM', onTerm)
|
|
157
|
+
/** Aborted as this worker leaves: what runs here then is stopped, not ended. */
|
|
158
|
+
const leaving = new AbortController()
|
|
155
159
|
const quitCode = (): number => 128 + constants.signals[quit.signal.reason as 'SIGINT' | 'SIGTERM']
|
|
160
|
+
/**
|
|
161
|
+
* The run is over for this worker (410), its job no failure. An end on
|
|
162
|
+
* the orchestrator's silence stops what runs here, and is said: three
|
|
163
|
+
* workers' every task stopped in one second, each job green and silent,
|
|
164
|
+
* and the cause was found only in the run's profile (CI, 2026-10-29).
|
|
165
|
+
*/
|
|
166
|
+
const ended = (err: RelayError): number => {
|
|
167
|
+
if (err.message === ORCHESTRATOR_SILENT)
|
|
168
|
+
ui.print(
|
|
169
|
+
'err',
|
|
170
|
+
ui.status(
|
|
171
|
+
'warn',
|
|
172
|
+
'vx dte worker',
|
|
173
|
+
'the orchestrator went silent: the tasks running here were stopped, and the run gives them to other workers',
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
return 0
|
|
177
|
+
}
|
|
156
178
|
|
|
157
179
|
/** Saves the task's files (none when `bare`) and output under `key` and says so. */
|
|
158
180
|
const reply = async (
|
|
@@ -162,9 +184,11 @@ export async function runWorker(opts: {
|
|
|
162
184
|
res: WiredResult & { output: CapturedChunk[] },
|
|
163
185
|
bare = false,
|
|
164
186
|
): Promise<void> => {
|
|
165
|
-
// Stopped by a signal
|
|
166
|
-
//
|
|
167
|
-
|
|
187
|
+
// Stopped by a signal, or by this worker leaving (its orchestrator went
|
|
188
|
+
// silent): its end would fail the task in the run, which runs it again
|
|
189
|
+
// elsewhere once this worker leaves. One such end reached the
|
|
190
|
+
// orchestrator and failed the run (2026-11-05).
|
|
191
|
+
if (quit.signal.aborted || leaving.signal.aborted) return
|
|
168
192
|
const { output, ...result } = res
|
|
169
193
|
// Forced: the key keeps an entry it holds, so the run's machines restore
|
|
170
194
|
// the fresh bytes under the run key.
|
|
@@ -205,26 +229,29 @@ export async function runWorker(opts: {
|
|
|
205
229
|
true,
|
|
206
230
|
)
|
|
207
231
|
}
|
|
208
|
-
// No remote took it
|
|
209
|
-
//
|
|
232
|
+
// No remote took it after its tries: the run's other machines read the
|
|
233
|
+
// remotes alone, so the task is this worker's fault, run elsewhere.
|
|
210
234
|
const saved = keys.at(-1)!
|
|
211
|
-
|
|
212
|
-
(
|
|
213
|
-
? { ...result, unshared: true, output: pieces(output) }
|
|
214
|
-
: result
|
|
235
|
+
if ((await artifacts.uploaded?.(saved)) === false)
|
|
236
|
+
throw new Error(`no remote cache took its entry ${saved} (this worker's log says why)`)
|
|
215
237
|
if (result.exitCode === 0)
|
|
216
238
|
for (const k of keys) {
|
|
217
239
|
present.set(k, Promise.resolve())
|
|
218
240
|
holds.set(k, names(m.project, m.outputs))
|
|
219
241
|
}
|
|
242
|
+
const endedAt = Date.now()
|
|
243
|
+
const t = times.get(m.task)
|
|
220
244
|
const end: ToOrchestrator = {
|
|
221
245
|
type: 'end',
|
|
222
246
|
task: m.task,
|
|
223
247
|
key: saved,
|
|
224
|
-
result
|
|
248
|
+
result,
|
|
249
|
+
startedAt: t?.startedAt ?? endedAt,
|
|
250
|
+
ranAt: t?.ranAt ?? endedAt,
|
|
251
|
+
endedAt,
|
|
225
252
|
load: frame(),
|
|
226
253
|
}
|
|
227
|
-
for (const part of parts(end)) await
|
|
254
|
+
for (const part of parts(end)) await say(part)
|
|
228
255
|
}
|
|
229
256
|
|
|
230
257
|
// A message the relay would not take is said, each kind once (2026-10-23):
|
|
@@ -240,15 +267,47 @@ export async function runWorker(opts: {
|
|
|
240
267
|
what,
|
|
241
268
|
'the orchestrator',
|
|
242
269
|
)
|
|
270
|
+
/** Each running task's times: begun, and its command's end once it ran. */
|
|
271
|
+
const times = new Map<string, { startedAt: number; ranAt?: number }>()
|
|
272
|
+
/** Tasks whose command ended, not said yet: each rides the next message. */
|
|
273
|
+
const ran = new Set<string>()
|
|
274
|
+
/** The last batch read: its report names what it runs as of that batch. */
|
|
275
|
+
let batch = 0
|
|
276
|
+
/**
|
|
277
|
+
* `m` to the orchestrator in one frame with its machine's load, which
|
|
278
|
+
* every frame carries, and each waiting `ran`: none is ever sent alone.
|
|
279
|
+
*/
|
|
280
|
+
const say = (event: ToOrchestrator): Promise<void> => {
|
|
281
|
+
const m = { ...event, load: { ...frame(), at: Date.now() } } as ToOrchestrator
|
|
282
|
+
// Its `end` says it ran.
|
|
283
|
+
if (m.type === 'end') ran.delete(m.task)
|
|
284
|
+
const riding: ToOrchestrator[] = []
|
|
285
|
+
for (const task of ran) {
|
|
286
|
+
const t = times.get(task)
|
|
287
|
+
if (t?.ranAt !== undefined)
|
|
288
|
+
riding.push({ type: 'ran', task, startedAt: t.startedAt, ranAt: t.ranAt })
|
|
289
|
+
}
|
|
290
|
+
ran.clear()
|
|
291
|
+
return me.send(
|
|
292
|
+
'orchestrator',
|
|
293
|
+
riding.length === 0 ? m : { type: 'together', messages: [...riding, m] },
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
// A heartbeat with nothing said: its live report, which is how the
|
|
297
|
+
// orchestrator hears it. Never a second in one: any message carries load.
|
|
298
|
+
me.quiet(() => {
|
|
299
|
+
void say({ type: 'report', running: [...running.keys()], batch, load: frame() }).catch(
|
|
300
|
+
unsent('report'),
|
|
301
|
+
)
|
|
302
|
+
})
|
|
243
303
|
/** Runs a task the orchestrator sent: it fits what this worker has free. */
|
|
244
304
|
const start = (m: RunOrder): void => {
|
|
245
|
-
|
|
305
|
+
const t: { startedAt: number; ranAt?: number } = { startedAt: Date.now() }
|
|
306
|
+
times.set(m.task, t)
|
|
246
307
|
const done = (): void => {
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
.send('orchestrator', { type: 'ran', task: m.task, load: frame() })
|
|
251
|
-
.catch(unsent('ran'))
|
|
308
|
+
if (t.ranAt !== undefined) return
|
|
309
|
+
t.ranAt = Date.now()
|
|
310
|
+
ran.add(m.task)
|
|
252
311
|
}
|
|
253
312
|
const p = runTask(m, done)
|
|
254
313
|
.catch(async (err: unknown) => {
|
|
@@ -268,11 +327,13 @@ export async function runWorker(opts: {
|
|
|
268
327
|
}
|
|
269
328
|
const reason = err instanceof Error ? err.message : String(err)
|
|
270
329
|
ui.print('err', ui.status('error', m.task, `could not run: ${reason}`))
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
330
|
+
ran.delete(m.task)
|
|
331
|
+
await say({ type: 'fault', task: m.task, reason }).catch(unsent('fault'))
|
|
332
|
+
})
|
|
333
|
+
.finally(() => {
|
|
334
|
+
work.delete(p)
|
|
335
|
+
times.delete(m.task)
|
|
274
336
|
})
|
|
275
|
-
.finally(() => work.delete(p))
|
|
276
337
|
work.add(p)
|
|
277
338
|
}
|
|
278
339
|
|
|
@@ -302,10 +363,6 @@ export async function runWorker(opts: {
|
|
|
302
363
|
const runTask = async (m: RunOrder, free: () => void): Promise<void> => {
|
|
303
364
|
const stop = new AbortController()
|
|
304
365
|
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
366
|
try {
|
|
310
367
|
const differs = await runtimeDiffers(m)
|
|
311
368
|
if (differs !== undefined) {
|
|
@@ -386,6 +443,8 @@ export async function runWorker(opts: {
|
|
|
386
443
|
? undefined
|
|
387
444
|
: new Set(m.sandbox.keyed.map((k) => path.join(opts.root, k))),
|
|
388
445
|
m.nested.map((n) => path.join(opts.root, n)),
|
|
446
|
+
undefined,
|
|
447
|
+
(m.sandbox.reads ?? []).map((r) => path.join(opts.root, r)),
|
|
389
448
|
)
|
|
390
449
|
const output = new BoundedCapture()
|
|
391
450
|
const req: ExecuteRequest = {
|
|
@@ -510,13 +569,6 @@ export async function runWorker(opts: {
|
|
|
510
569
|
}
|
|
511
570
|
}
|
|
512
571
|
|
|
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
572
|
try {
|
|
521
573
|
for (;;) {
|
|
522
574
|
let messages
|
|
@@ -524,7 +576,7 @@ export async function runWorker(opts: {
|
|
|
524
576
|
messages = await me.poll(quit.signal)
|
|
525
577
|
} catch (err) {
|
|
526
578
|
if (quit.signal.aborted) return quitCode()
|
|
527
|
-
if (err instanceof RelayError && err.status === 410) return
|
|
579
|
+
if (err instanceof RelayError && err.status === 410) return ended(err)
|
|
528
580
|
// The relay lost it (silent too long, or restarted): the run gave its
|
|
529
581
|
// tasks to others, so this job has nothing left and is no failure.
|
|
530
582
|
if (err instanceof RelayError && err.status === 404) {
|
|
@@ -568,30 +620,47 @@ export async function runWorker(opts: {
|
|
|
568
620
|
// Every batch of a run names the same orchestrator root.
|
|
569
621
|
root = m.root
|
|
570
622
|
realRoot = m.realRoot
|
|
623
|
+
batch = m.n
|
|
571
624
|
if (m.sandboxes !== undefined) {
|
|
572
625
|
if (floor === undefined) floor = localFloor(m.sandboxes)
|
|
573
626
|
else await floor.rearm(m.sandboxes)
|
|
574
627
|
}
|
|
575
628
|
for (const t of m.tasks) {
|
|
576
|
-
|
|
577
|
-
// relay was away): it is not run twice.
|
|
578
|
-
if (running.has(t.task)) continue
|
|
579
|
-
const deps = ahead.get(t.task)
|
|
629
|
+
const sent = ahead.get(t.task)
|
|
580
630
|
ahead.delete(t.task)
|
|
581
|
-
|
|
631
|
+
// Learned in the order sent, a task run already too: the run counts them sent.
|
|
632
|
+
let missing: string | undefined
|
|
633
|
+
const deps = (sent === undefined ? t.deps : [...sent, ...t.deps]).map((d): DepOrder => {
|
|
634
|
+
if (typeof d !== 'string') {
|
|
635
|
+
known.set(d.task, d)
|
|
636
|
+
return d
|
|
637
|
+
}
|
|
638
|
+
const got = known.get(d)
|
|
639
|
+
if (got === undefined) missing ??= d
|
|
640
|
+
return got!
|
|
641
|
+
})
|
|
642
|
+
// One it runs already is not run twice.
|
|
643
|
+
if (running.has(t.task)) continue
|
|
644
|
+
if (missing !== undefined) {
|
|
645
|
+
const reason = `was sent ${missing} by name, never whole`
|
|
646
|
+
ui.print('err', ui.status('error', t.task, `could not run: ${reason}`))
|
|
647
|
+
await say({ type: 'fault', task: t.task, reason }).catch(unsent('fault'))
|
|
648
|
+
continue
|
|
649
|
+
}
|
|
650
|
+
start({ ...t, deps })
|
|
582
651
|
}
|
|
583
652
|
}
|
|
584
653
|
}
|
|
585
654
|
}
|
|
586
655
|
} catch (err) {
|
|
587
656
|
// The run closed under a poll or an answer: nothing is left for this worker.
|
|
588
|
-
if (err instanceof RelayError && err.status === 410) return
|
|
657
|
+
if (err instanceof RelayError && err.status === 410) return ended(err)
|
|
589
658
|
throw err
|
|
590
659
|
} finally {
|
|
591
660
|
process.off('SIGINT', onInt)
|
|
592
661
|
process.off('SIGTERM', onTerm)
|
|
593
|
-
clearInterval(held)
|
|
594
662
|
trees.stop()
|
|
663
|
+
leaving.abort()
|
|
595
664
|
for (const c of running.values()) c.abort()
|
|
596
665
|
await Promise.all(work)
|
|
597
666
|
const onlineMs = performance.now() - started.at
|
|
@@ -988,25 +1057,6 @@ const gb = (n: number): string => `${(n / 1e9).toFixed(1)} GB`
|
|
|
988
1057
|
/** The most of a result one send carries: a relay frame is 1 MiB, which sealing grows by a third. */
|
|
989
1058
|
const PART_BYTES = 256 * 1024
|
|
990
1059
|
|
|
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
1060
|
/**
|
|
1011
1061
|
* `end` as its sends: itself when it fits one frame, else its long lists
|
|
1012
1062
|
* first, in parts that each fit, and then the rest of it.
|