@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 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 peer probed (Switchboard § Delivery, § Liveness).
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. A run that dies before it cancels
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 begun, a command ended, a task saved,
460
- its heartbeat) carries its machine's load: cores busy over the last
461
- second and memory in use. A task goes to it only when it fits now: while
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 (too big, a 429) is kept the same way: its output comes to the orchestrator with
539
- its result, its files stay on the worker, and a task on the
540
- orchestrator's machine that needs them fails saying so. Before a task
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 had begun fails, naming both; one an
580
- worker had not begun yet does not count. With no worker of the run left
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. Leases
591
- wait while either is away, and a worker sent a task it runs already
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. A peer quiet on a socket that never
615
- closed is lost within two heartbeats (4 s on the default): only a
616
- peer heard from for none of a heartbeat and a half is probed (an
617
- empty numbered send), which it acks at once; either side hearing
618
- nothing from the other for 1.9 heartbeats loses it, a worker by
619
- ending. A quiet pair trades one probe and its ack each heartbeat and a
620
- half, the relay answers neither, and nobody pings. An orchestrator
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 each once,
627
- in order. An ack rides the next send back, and those with none to ride
628
- go together in one frame per half heartbeat: no message of its own on
629
- a busy run.
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 begun tasks run again on
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 stops answering the orchestrator's probes, or at
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 sent to a worker but not yet
674
- begun there has no row; the tasks line counts it `scheduled`
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 the worker begins it. A worker is `online` while the run
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.649",
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.649"
32
+ "@vzn/vx-dte-server": "0.0.651"
33
33
  },
34
34
  "peerDependencies": {
35
- "@vzn/vx": "^0.0.649"
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
- return cancelOf(run)
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
- const id =
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?: number }).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, CapturedChunk, ExecuteResult, SandboxConfig } from '@vzn/vx'
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
- /** Tasks that fit what it has free, each begun at once; `root` is swapped for the worker's in `env`. */
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
- /** It began the task. */
153
- | { type: 'start'; task: string; load?: Load }
154
- /** Its command is done; its outputs save now, its cores are free. */
155
- | { type: 'ran'; task: string; load?: Load }
156
- /** Each heartbeat while it runs any: what it runs, which renews its lease on each. */
157
- | { type: 'held'; running: string[]; load?: Load }
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
- | { type: 'end'; task: string; key: string; result: WiredResult; load?: Load }
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', 'output'] as const
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
  /**