@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/src/wire.ts CHANGED
@@ -42,18 +42,24 @@ export function unsent(
42
42
  */
43
43
  const FIRST_REDIAL_MS = 250
44
44
  /**
45
- * The orchestrator probes a worker quiet this many heartbeats: each probe
46
- * and its ack are two frames, so a peer heard from is never asked.
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 PROBE_BEATS = 1.5
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 peer quiet this many is lost: a live one, acking a probe at once, is
53
- * heard within a round trip of it. Checked each tenth, loss lands by two
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 LOST_BEATS = 1.9
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 probe delivers: nothing. */
65
- const PROBED = Symbol('probe')
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
- * A peer quiet a heartbeat is asked: a numbered send with no body, which
125
- * its receiver acknowledges at once and delivers to no one.
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 Probe {
133
+ interface Beat {
128
134
  readonly seq: number
129
135
  readonly acked?: number
130
136
  }
131
- const isProbe = (b: unknown): b is Probe =>
137
+ const isBeat = (b: unknown): b is Beat =>
132
138
  typeof b === 'object' &&
133
139
  b !== null &&
134
- typeof (b as Probe).seq === 'number' &&
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
- * Acks owed, by peer: each rides the next send to its peer; those with
260
- * none in half a heartbeat go together in one frame, before any peer
261
- * sends again, in place of a ping.
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
- const upTo = taken.get(peer)?.upTo
271
- if (upTo === undefined) continue
272
- // quiet: a lost ack only costs a resend, which is dropped as seen
273
- put(peer, { acked: upTo } satisfies Acked).catch(() => undefined)
274
- }
275
- owing.clear()
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
- /** `now`: a probe's ack goes at once, so its asker hears it within the loss window. */
278
- const ack = (to: string, now = false): void => {
279
- owing.add(to)
280
- if (now) return flushAcks()
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
- /** The ack owed `to`, if any, for a send to it to carry. */
286
- const owed = (to: string): { acked?: number } =>
287
- owing.delete(to) ? { acked: taken.get(to)!.upTo } : {}
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
- * `PROBE_BEATS` is probed; one quiet `LOST_BEATS` is lost
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`: an ack, a probe or a resend, which waits on no answer: the relay says only a failure. */
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 (isProbe(body)) {
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, PROBED)
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 !== PROBED) out.push({ from, body: next })
375
+ if (next !== BEAT) out.push({ from, body: next })
339
376
  }
340
- ack(from, true)
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 !== PROBED) out.push({ from, body: next })
392
+ if (next !== BEAT) out.push({ from, body: next })
350
393
  }
351
- ack(from)
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
- * Only a quiet peer is probed: the orchestrator a worker quiet
386
- * `PROBE_BEATS`, a worker its orchestrator quiet a tenth longer, so in a
387
- * quiet pair the orchestrator's probe comes first and the pair trades
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
- const quiet = now - at
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, 'relay: the orchestrator went silent'))
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
- } else if (
409
- quiet >= (role === 'orchestrator' ? PROBE_BEATS : WORKER_PROBE_BEATS) * heartbeatMs &&
410
- // a numbered send's ack may be deferred past the loss line; a probe's is not
411
- ![...(outbox.get(peer)?.unacked.values() ?? [])].some((u) => u.body === PROBED)
412
- ) {
413
- let o = outbox.get(peer)
414
- if (o === undefined) outbox.set(peer, (o = { next: 0, unacked: new Map() }))
415
- const seq = ++o.next
416
- o.unacked.set(seq, { body: PROBED, at: now })
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 === PROBED ? { seq } : { seq, body: 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 what went
586
- // unacknowledged a heartbeat goes again before its receiver loses this.
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: PROBED, at: 0 })
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 (an ack, a probe): no redial
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: an ack or probe to one the relay no
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, DepOrder[]>()
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: its end would fail the task in the run, which
166
- // runs it again elsewhere once this worker leaves.
167
- if (quit.signal.aborted) return
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: the orchestrator reads the remotes alone, so its
209
- // dependants come here and its output goes with the `end` (2026-10-25).
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
- const wired: WiredResult =
212
- (await artifacts.uploaded?.(saved)) === false
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: wired,
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 me.send('orchestrator', part)
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
- let ran = false
305
+ const t: { startedAt: number; ranAt?: number } = { startedAt: Date.now() }
306
+ times.set(m.task, t)
246
307
  const done = (): void => {
247
- if (ran) return
248
- ran = true
249
- void me
250
- .send('orchestrator', { type: 'ran', task: m.task, load: frame() })
251
- .catch(unsent('ran'))
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
- await me
272
- .send('orchestrator', { type: 'fault', task: m.task, reason })
273
- .catch(unsent('fault'))
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 0
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
- // One it runs already, sent again (its lease ended while the
577
- // relay was away): it is not run twice.
578
- if (running.has(t.task)) continue
579
- const deps = ahead.get(t.task)
629
+ const sent = ahead.get(t.task)
580
630
  ahead.delete(t.task)
581
- start(deps === undefined ? t : { ...t, deps: [...deps, ...t.deps] })
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 0
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.