@vzn/vx-dte 0.0.651 → 0.0.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,8 @@ Website: <https://vznjs.github.io/vx/> · source: <https://github.com/vznjs/vx>
5
5
  Distributed task execution for vx: one `vx run` spread over workers your
6
6
  own CI starts, replayed here as one run. The run you start is the
7
7
  orchestrator. It runs no task itself, only restores cache hits: it starts
8
- workers and hands the ready tasks to the workers with room, critical path first. A worker runs it in its own checkout and saves the task's
8
+ workers and hands the ready tasks to the workers with room, critical path first,
9
+ ranked by history and ranked again when fetched timings land. A worker runs it in its own checkout and saves the task's
9
10
  output and the files its declared outputs left to the workspace's cache,
10
11
  under the task's key. They land here before the task ends, so the run
11
12
  reports the task as its own. No file travels through the relay: it
@@ -64,12 +65,12 @@ the initiator.
64
65
  anyone who reads the repository's Actions sees. A dispatch GitHub
65
66
  answers without a run id (204) is refused, so its workers exit.
66
67
 
67
- | Who | `info` / `body` |
68
- | ------------- | ------------------------------------------------------- |
69
- | worker `join` | `info: { "noise": <message 1>, "token"? }` |
70
- | orchestrator | `{ "type": "hello", "noise": <message 2>, "token"? }` |
71
- | orchestrator | `{ "type": "refused", "reason" }`, plain, then `remove` |
72
- | either, then | `{ "seal": ["<n>.<ciphertext>", …] }` |
68
+ | Who | `info` / `body` |
69
+ | ------------- | ---------------------------------------------------------- |
70
+ | worker `join` | `info: { "noise": <message 1>, "token"? }` |
71
+ | orchestrator | `{ "type": "hello", "noise": <message 2>, "token"? }` |
72
+ | orchestrator | `{ "type": "refused", "reason" }`, plain, then `remove` |
73
+ | either, then | `["<n>.<ciphertext>", …]`, or `["+", …]` with more to come |
73
74
 
74
75
  The orchestrator checks each worker the moment the relay says it joined,
75
76
  and `remove`s one whose handshake fails. Message 1's payload is the
@@ -78,17 +79,28 @@ body is Noise transport messages, base64, each at most 65535 bytes and
78
79
  naming its nonce `n` (Noise §11.4, since a relay may reorder); each is
79
80
  sealed with associated data `<first n>.<parts>` of its body, so no part
80
81
  opens in another body, and a body's parts take consecutive nonces, so
81
- none is repeated or reordered in it. A body whose first nonce was seen before is a
82
+ none is repeated or reordered in it. A body of more than 5 parts goes
83
+ over several sends of 5, each but the last led by `"+"`, so no frame
84
+ passes the relay's 1 MiB; the receiver holds them until the last and
85
+ opens the body whole. A body whose first nonce was seen before is a
82
86
  replay and is dropped; one older than a body already opened (the relay
83
87
  held it back: the wire's numbers are outside the seal) or that does not
84
- open loses its sender. The
85
- plaintext is JSON, or over 256 bytes and smaller so, a `0` byte, the
86
- zstd frame's length (4 bytes, big-endian) and the frame: compressed
87
- before sealing, since no relay or socket compresses what is sealed. It
88
+ open loses its sender. The plaintext is a lead byte, the length of
89
+ every body its sender sealed before (4 bytes, big-endian, mod 2³²), and
90
+ then, `1`, its JSON, an array of the messages sent together (up to 64
91
+ KiB), or `0`, when smaller so, its zstd frame's length (4 bytes) and
92
+ the frame: compressed before sealing, since no
93
+ relay or socket compresses what is sealed. The orchestrator's are
94
+ compressed against the last 16 KiB of its bodies' JSON to that worker
95
+ as their dictionary, so a batch's env and paths said before cost a few
96
+ bytes; a worker's go without one, which would cost the orchestrator
97
+ some 100 µs to open each. A body whose length is not
98
+ the receiver's own count was sealed after one it never opened, which the
99
+ relay kept back, and loses its sender. It
88
100
  is padded with trailing spaces to its size class, a power of two from
89
- 256 bytes, then whole transport messages, so the relay learns no size
101
+ 64 bytes, then whole transport messages, so the relay learns no size
90
102
  within a class. Inside, each send is numbered and acknowledged
91
- end to end, and a quiet worker reports (Switchboard § Delivery, § Liveness).
103
+ end to end (`{ "n", "b", "a"? }`: its number, body and ack), and a quiet worker reports (Switchboard § Delivery, § Liveness).
92
104
 
93
105
  ## Try it
94
106
 
@@ -104,7 +116,7 @@ import { dte, local } from '@vzn/vx-dte'
104
116
  export default defineWorkspace({
105
117
  plugins: [
106
118
  dte({
107
- workers: { small: { max: 4, cpu: 2, memory: '8GB' } },
119
+ workers: { linux: { machines: { small: { max: 4, cpu: 2, memory: '8GB' } } } },
108
120
  adapter: local({ prepare: 'bun install' }),
109
121
  }),
110
122
  ],
@@ -120,8 +132,9 @@ runs here as always (on GitHub Actions, `github()` with a relay names one
120
132
  itself). It is a random UUID, so no one can guess a run to
121
133
  open it first or join it; vx and the relay refuse any other. `local()` starts each worker as a process in its own
122
134
  git worktree of the tracked files as they are, uncommitted changes
123
- included (untracked files are not: a task that keys one fails; a run with
124
- an unmerged index, mid-merge, is refused), at the
135
+ included, and a file mid-merge or flagged `assume-unchanged` or
136
+ `skip-worktree` as it is on disk (untracked files are not: a task that keys
137
+ one fails), at the
125
138
  workspace's place in it (a workspace in a subdirectory of its repository too),
126
139
  saving to the run's store (a relative `cache.dir` or `--cache-dir` too).
127
140
  A run from a git hook works too: the worktree and its worker drop the
@@ -130,7 +143,10 @@ index being committed is left as it is. When a worker fails, its row names
130
143
  the error line and the log that holds its whole stderr and all `prepare`
131
144
  printed. At the run's end,
132
145
  or on Ctrl-C, vx exits without waiting for the worktrees to be removed:
133
- a detached shell removes them and prunes git's records.
146
+ a detached shell waits up to 5 s for the workers to leave, kills any
147
+ still running, removes the worktrees and prunes git's records. A run whose vx
148
+ was killed (`kill -9`) removes none; the next `local()` run in the
149
+ repository removes them, once their run's process is gone.
134
150
 
135
151
  `docker()` starts each worker as a container held to its size: `--cpus`
136
152
  and `--memory` come from `cpu` and `memory`. The container clones this
@@ -148,7 +164,7 @@ names its error line and the log of its whole stderr and all `prepare` printed:
148
164
 
149
165
  ```ts
150
166
  dte({
151
- workers: { small: { max: 4, cpu: 2, memory: '4GB' } },
167
+ workers: { linux: { machines: { small: { max: 4, cpu: 2, memory: '4GB' } } } },
152
168
  adapter: docker({ image: 'my/bun-git', prepare: 'bun install' }),
153
169
  })
154
170
  ```
@@ -222,7 +238,7 @@ export default defineWorkspace({
222
238
  plugins: [
223
239
  dte({
224
240
  relay: 'https://relay.example.com',
225
- workers: { small: { max: 8, cpu: 4, memory: '16GB' } },
241
+ workers: { linux: { machines: { small: { max: 8, cpu: 4, memory: '16GB' } } } },
226
242
  adapter: github(),
227
243
  }),
228
244
  ],
@@ -285,7 +301,7 @@ workspace in a subdirectory of its repository gets them at the repository's
285
301
  root, its jobs running in that directory. Each size runs on
286
302
  its platform's GitHub-hosted runner (`ubuntu-latest` for none named,
287
303
  `ubuntu-24.04-arm`, `macos-latest`, `macos-15-intel`), or on the label
288
- `github({ runsOn: { small: 'ubuntu-latest-16-cores' } })` names, as
304
+ a machine's `runsOn: 'ubuntu-latest-16-cores'` names, as
289
305
  `gitlab({ tags })` does. It installs with the package manager your lockfile
290
306
  names and runs vx as `bun vx`, which runs the repo's own and never
291
307
  downloads one, as `pnpm exec` does (Yarn too: Bun reads no Plug'n'Play,
@@ -326,7 +342,7 @@ jobs:
326
342
  id-token: write
327
343
  # A worker of a run that is over cancels its own run.
328
344
  actions: write
329
- runs-on: ${{ fromJSON('{"small":"ubuntu-latest"}')[inputs.size] }}
345
+ runs-on: ${{ fromJSON('{"linux.small":"ubuntu-latest"}')[inputs.size] }}
330
346
  timeout-minutes: 30
331
347
  strategy:
332
348
  fail-fast: false
@@ -381,7 +397,7 @@ export default defineWorkspace({
381
397
  plugins: [
382
398
  dte({
383
399
  relay: 'https://relay.example.com',
384
- workers: { small: { max: 4, cpu: 2, memory: '8GB' } },
400
+ workers: { linux: { machines: { small: { max: 4, cpu: 2, memory: '8GB' } } } },
385
401
  adapter: gitlab(),
386
402
  }),
387
403
  ],
@@ -437,12 +453,12 @@ vx:
437
453
  - bun install --frozen-lockfile
438
454
  script: [bun vx dte worker]
439
455
 
440
- 'vx-worker small':
456
+ 'vx-worker linux.small':
441
457
  extends: .vx-worker
442
458
  parallel:
443
459
  matrix:
444
460
  - VX_DTE_SLOT: ['1', '2', '3', '4']
445
- rules: [{ if: $VX_DTE_SIZE == "small" && $VX_DTE_SLOT =~ $VX_DTE_PICK }]
461
+ rules: [{ if: $VX_DTE_SIZE == "linux.small" && $VX_DTE_SLOT =~ $VX_DTE_PICK }]
446
462
  ```
447
463
 
448
464
  `gitlab({ tags })` names each size's runner tags. Each job's ID token
@@ -485,6 +501,13 @@ use your own instead.
485
501
  done. The run starts a worker of each
486
502
  platform its work needs. A task none of whose platforms any size has
487
503
  refuses the run before a worker starts.
504
+ - A task naming `exec.worker` runs only on machines of that worker type;
505
+ any other on the first type, in declared order, with a machine of one
506
+ of its platforms. So a type listed last (a costly Mac) gets only the
507
+ tasks no other type can take. Core refuses the run before a worker
508
+ starts when no type has the name, or none of its machines runs one of
509
+ the task's platforms. A worker that joins on its own is of the first
510
+ type with its platform.
488
511
  - Tasks waiting for room go out longest tail first: a task's recorded
489
512
  duration and its longest chain of dependants, so a long chain that got
490
513
  ready late does not queue behind many short tasks ready at once.
@@ -501,9 +524,16 @@ use your own instead.
501
524
  A sandbox the worker cannot host (no `bwrap` in its image) fails the
502
525
  task there, as it fails here; the worker stays in the run.
503
526
  - A persistent task a task depends on starts on the worker that runs the
504
- task, once per worker, ready before it, and stops when the worker leaves;
527
+ task, once per worker, ready before it, and stops there once no task left
528
+ in the run uses it (the run's `unneeded`), or when the worker leaves;
505
529
  the orchestrator never starts it, and a worker no dependant reaches
506
- never does (2026-10-22).
530
+ never does (2026-10-22). Its CPU is learned on the worker each second
531
+ as `cores = base + each running dependant's cost`: the base from looks
532
+ with no dependant running, each cost from looks with it (a look on a
533
+ full machine only raises them). Each dependant's cost is added to its
534
+ CPU, so its history books it; the base is booked once on each worker
535
+ that starts the server, in placement and in the plan, and kept for the
536
+ next run under the commit, as warm-ups are.
507
537
  - A task whose key hashes a runtime probe (`inputs.runtime`, such as
508
538
  `bun --version`) runs on a worker, which answers the probe first and
509
539
  fails the task when it answers otherwise than this machine did. A path
@@ -525,9 +555,13 @@ use your own instead.
525
555
  or of files a formatter changed) is sent in parts, none of it dropped.
526
556
  An order too long for one frame (a task with thousands of deps) sends
527
557
  its deps ahead of it the same way.
558
+ - A worker gets each dependency in full once. Later orders name it, so a
559
+ deep graph's orders stay small.
528
560
  - A task runs with the orchestrator's environment under the worker's
529
561
  root. `HOME`, `TMPDIR`, `USER` and the like are the worker's own, unless
530
- the task's `env.define` sets them; `npm_execpath` is the workspace's
562
+ the task's `env.define` sets them or its `inputs.env` keys them: a keyed
563
+ one is the orchestrator's value, or unset where it is unset there, so
564
+ the worker runs on what the key saw; `npm_execpath` is the workspace's
531
565
  package manager on the worker, as core sets it on a local task.
532
566
  - A worker restores, by key, the declared outputs of every task the one
533
567
  it runs depends on, each after its own dependencies (an additive task's
@@ -538,7 +572,9 @@ use your own instead.
538
572
  leaves no file. One waits there up to 10 s; past that, with that worker
539
573
  gone or of another platform, or with its uncached upstreams on two
540
574
  workers, the upstream runs again where the dependant can go, and the
541
- dependant follows it. A task whose upload no remote took after three
575
+ dependant follows it. One whose `exec.worker` or `exec.platforms` rules
576
+ out where its dependant runs refuses the run before a worker starts,
577
+ naming both: cache it, or run both alike. A task whose upload no remote took after three
542
578
  tries (too big, a 429, a reset connection) is its worker's fault: that
543
579
  worker takes no more tasks and the task runs on another; a second fault
544
580
  fails the run. Before a task runs, its
@@ -560,20 +596,37 @@ use your own instead.
560
596
  `--no-cache` or read-only run, a dependant of a failure under
561
597
  `--continue=always`, one that left a process running that may still
562
598
  write its outputs, one a stopped run cancelled), and one that left a tracked file under its project
563
- changed on its worker (a formatter's input), which this machine never
564
- sees: core withholds its own save when the key read that file, and says so.
599
+ changed on its worker (a formatter's input), or that found an untracked,
600
+ unignored file under its project there (its outputs and its dependencies'
601
+ aside), which this machine never sees: core withholds its own save when
602
+ the key read that file or its inputs declare it, and says so.
565
603
  - Under `--force`, a success is saved under its key (which keeps an entry
566
604
  it already holds, as core does) and under the run's key, which this
567
605
  machine and the dependants' workers restore: the bytes this run built,
568
606
  never the entry it bypassed.
569
607
  - A worker whose tracked files differ from the orchestrator's (another
570
608
  tree, or the same commit with a tracked file changed, such as by a
571
- prepare step) is refused. Both sides read their tracked files as they
572
- are, so the same change on both is the same tree.
609
+ prepare step) is refused, and the orchestrator says so as it happens:
610
+ `[vx] dte: refused worker <id>: <reason>`. Both sides read their tracked files as they
611
+ are on disk, a file flagged `assume-unchanged` or `skip-worktree` too
612
+ (one a sparse checkout left off disk is its index's), so the same change
613
+ on both is the same tree.
614
+ - A worker runs the commit's bytes and modes, which are the ones the key
615
+ read: the run's snapshot stages each file's executable bit as on disk
616
+ (under `core.fileMode=false` too, as core keys it), and a worker writes
617
+ back as the commit holds it each file a git filter of its own checked
618
+ out otherwise (`text`, `eol`, `ident`, `filter` such as LFS,
619
+ `working-tree-encoding`, `core.autocrlf`). A task whose key hashes a
620
+ tracked file that is on disk other than the commit holds it (CRLF such
621
+ a filter wrote, an expanded ident, an LFS file's content) fails, naming
622
+ the file: no worker builds on those bytes.
573
623
  - A task whose key hashes a file the run's commit lacks (untracked, or
574
624
  gitignored and named outright, or inside a submodule, whose files the
575
625
  commit holds only as a gitlink) fails, naming the file: no worker has it.
576
626
  A dependency's declared output is not one: its worker restores it first.
627
+ A task whose project the commit lacks (its config untracked) fails,
628
+ naming the project, and a run whose `.vx/workspace.*` is untracked is
629
+ refused before a worker starts.
577
630
  - A task's `exec.timeout` counts from its start on its worker: the wait
578
631
  for a worker still booting is not its run (the executor declares
579
632
  `timeouts`).
@@ -582,11 +635,22 @@ use your own instead.
582
635
  another. A task two such workers were sent fails, naming both; one whose
583
636
  send failed does not count. With no worker of the run left
584
637
  to take it and none starting, it fails at once.
638
+ - A task that failed and retries (its `retries`, or the one retry a
639
+ known-flaky task gets) runs on another worker than the attempt that
640
+ failed, as core asks (`ExecuteRequest.attempt`): the worker, or what
641
+ ran beside it, may be the flake. With no other worker of its platform
642
+ in the run, it runs there again rather than wait.
585
643
  - A relay that answers nothing for 10 s, every try counted, fails every task not done
586
644
  (`the relay is unreachable`); none runs here, which may be far smaller
587
645
  than the workers. What finished is saved, so a rerun resumes from it.
588
- A shorter fault (a `5xx` while the host restarts the relay, a dropped
589
- connection) is asked through, by the orchestrator and workers alike.
646
+ The footer counts each worker that had joined `lost`, with what it ran.
647
+ A fault (a `5xx`, a dropped or refused connection) is tried again 3
648
+ times, by the orchestrator and workers alike, and the third failed
649
+ retry ends them (`no socket in 3 dials`). The waits are random, from
650
+ up to 250 ms doubling to up to half a heartbeat, about 3 s at most in
651
+ all on the default, so a relay down or restarting is asked through
652
+ only when it is back within them: one down under a second usually is,
653
+ one down 2 s usually fails the run.
590
654
  - The relay stores nothing, so a restart forgets the run. The orchestrator
591
655
  joins again as its host and each worker joins again as itself, for two
592
656
  heartbeats (4 s on the default); tasks keep running meanwhile, and what
@@ -594,9 +658,12 @@ use your own instead.
594
658
  keeps what it runs, and one sent a task it runs already
595
659
  does not run it again, so no work repeats. One not back by then
596
660
  ends: a worker's tasks run elsewhere, an orchestrator's run fails.
597
- Joining has the same 10 s in all, its socket and request together: a
661
+ Joining has the same 10 s in all and the same 3 retries, its waits
662
+ spread over the 10 s (1.25 s doubling, jittered to half: 4.4 to 8.75 s
663
+ in all), so a relay or network down a few seconds is still joined. A
598
664
  relay silent that long fails the run with `cannot connect to the relay
599
- <origin>: no answer in 10 s`.
665
+ <origin>: no answer in 10 s`; four failed tries say why and how long,
666
+ as `cannot connect to the relay <origin>: ENOTFOUND, 4 dials in 9.1 s`.
600
667
  - A worker that never joins says why on its row (`failed exited 1:
601
668
  …`); its job ending, a success too, is what says so. Once the first
602
669
  worker of a size joins, each other of that size not joined within twice
@@ -613,8 +680,8 @@ use your own instead.
613
680
  - Only `leave` ends a participant at once (2026-10-25): the
614
681
  orchestrator's at its run's end is the final call that closes the run,
615
682
  and a worker's when it is done. Either whose socket drops without it
616
- dials again, 3 times within 10 s, and the relay keeps it, and its run,
617
- for those 10 s; past them the run is over. Either side hearing nothing
683
+ dials again, 3 times as above and within 10 s, and the relay keeps it,
684
+ and its run, for 10 s; past either the run is over. Either side hearing nothing
618
685
  from the other for 3 heartbeats (6 s on the default) loses it, and the
619
686
  rest follows from that: a worker that said nothing for half of it (3 s)
620
687
  sends its report; the orchestrator never speaks on a timer, only orders
@@ -657,14 +724,16 @@ on its frame, in that worker's own color, by size and the order the run
657
724
  started it:
658
725
 
659
726
  ```
660
- ⏺︎ 308ms success miss c#build · small#1
727
+ ⏺︎ 308ms success miss c#build · linux.small#1
661
728
  ```
662
729
 
663
730
  An idle worker the rest of the run cannot keep busy leaves (`left`): one
664
731
  beyond the peak of the cores the remaining tasks keep busy, so a lull
665
732
  between two wide parts keeps it. One a remaining task needs for its size
666
733
  stays, and so does one a later task needs when its size's `max` is
667
- spent, since none could start in its place. A lost worker's tasks run again on
734
+ spent, since none could start in its place. With that `max` spent, the
735
+ last worker of its platform also stays while any task of that platform
736
+ has not ended: a hit whose restore fails runs after all. A lost worker's tasks run again on
668
737
  another, and the run starts one in its place. A worker is lost when its
669
738
  socket closes, when it says nothing for 3 heartbeats (6 s), or at
670
739
  once when its adapter sees its process or job end. Workers start at the first
@@ -677,7 +746,7 @@ workspace's cache under its commit, and the next commit's run plans by it
677
746
  before any worker of that size joins.
678
747
 
679
748
  The `workers` rows, live while the run goes and again in the footer,
680
- give each worker its status (`starting`, `online`, `left`, `gone`, or `failed`
749
+ give each worker its status (`starting`, `online`, `left`, `lost`, or `failed`
681
750
  with why, such as `exited 1: Cannot find package '@vzn/vx-dte'`; once the
682
751
  run is over, one still starting is `not joined`), the tasks it
683
752
  has done, failed or had aborted by a stop, and the ones it runs now; while the run goes, those that left
@@ -695,28 +764,32 @@ declares and its machine reports, green when they agree and red when
695
764
  not:
696
765
 
697
766
  ```
698
- workers small#1 online 3 done · 2 running
699
- small#2 starting
767
+ workers linux.small#1 online 3 done · 2 running
768
+ linux.small#2 starting
700
769
  local online 1 done
701
770
  ```
702
771
 
703
772
  ```
704
- workers small#1 stopped 5 done · warm-up 0.1s
773
+ workers linux.small#1 stopped 5 done · warm-up 0.1s
705
774
  declared 2c/8G · actual 4c/16G
706
- small#2 lost 1 done · warm-up 0.2s
775
+ linux.small#2 lost 1 done · warm-up 0.2s
707
776
  declared 2c/8G · actual 4c/16G
708
777
  local online 1 done
709
778
  actual 10c/32G
710
779
  ```
711
780
 
712
781
  A run over that started none (every task a hit) says
713
- `workers none started: no task needed one`.
782
+ `workers none started: no task needed one`; one where a task waited
783
+ on a worker and none joined says
784
+ `workers none joined: a task waited on one`, or, when the run refused
785
+ workers, `workers none joined: 2 refused (<first reason>)`.
714
786
 
715
787
  Under each worker, and `local`, the footer then spells out what it did:
716
788
  its time online, busy and idle; its CPU peak and average in cores and
717
789
  the cores the run booked for the tasks it was running, on average (a
718
- booked from a task's start to its command's end); its cpu utilization (the core-time its
719
- tasks used over the core-time it had); its memory peak and average, when its tasks reported any; and, for a worker, the messages and
790
+ booked from a task's start to its command's end); its cpu utilization from its first task's send to its last
791
+ one's end, the warm-up and the wait for a first task left out: its machine's (any process's busy core-time over
792
+ the core-time it had, so what is left is idle cores), its tasks' and its worker process's; its memory peak and average, when its tasks reported any; and, for a worker, the messages and
720
793
  bytes the run sent it and received from it. The `probe` line says when
721
794
  core's cache probe answered, how many tasks it left to run (held in no
722
795
  cache, or not answered for) and how many of them it found in none. The `asked` line says each
@@ -733,20 +806,24 @@ the worker started, any process's, as its last frame said. Where the
733
806
  rest went, the worker says in its own log as it leaves: `cpu over 61.2s:
734
807
  machine 2.80 cores · tasks 2.00 cores · this worker 0.15 cores · its other
735
808
  children 0.05 cores · most besides: Runner.Worker 0.30 cores`, the last
736
- from one `ps` as it starts and one as it leaves. The `total` line is the cpu utilization of every
809
+ from one `ps` as it starts and one as it leaves. The `total` line is the same cpu utilization of every
737
810
  machine together, and the share of their history the run's tasks used:
738
811
 
739
812
  ```
740
- small#1 stopped 5 done · warm-up 0.2s
813
+ linux.small#1 stopped 5 done · warm-up 0.2s
741
814
  declared 4c/14G · actual 4c/14G
742
815
  online 12.1s · busy 9.8s · idle 2.3s
743
- cpu peak 3.6 · used avg 2.4 cores · machine avg 2.9 · booked avg 3.8 · cpu utilization 61%
816
+ cpu peak 3.6 · used avg 2.4 cores · machine avg 2.9 · booked avg 3.8
817
+ cpu utilization machine 78% · tasks 64% · worker 2%
744
818
  memory peak 1.2 GB · avg 640.0 MB
745
819
  sent 6 messages · 3.1 MB, received 15 messages · 8.4 MB
746
820
  probe at 0.4s: 5 tasks left to run, 5 known misses
747
821
  asked 1 at 0.5s for 5 tasks (5 measured), plan 12.0s
748
822
  ideal 10.8s: each task as long as it took, placed perfectly
749
- total cpu utilization 58%
823
+ best 2 × linux.small (4c/14G): 11.4s, the fewest of its sizes
824
+ more 3 × linux.small (4c/14G): 10.8s, past its sizes' max
825
+ any 1 × (8c/28G): 10.9s, of any cores, the least core time
826
+ total cpu utilization machine 74% · tasks 60% · worker 2%
750
827
  ```
751
828
 
752
829
  `vx run --profile[=<path>]` adds the run in time to the profile it
@@ -776,6 +853,17 @@ real warm-up. That ideal end (`TaskExecutor.idealMs`) sits beside the
776
853
  real time in the run summary and the footer's `ideal` line, and
777
854
  `@vzn/vx-ci` prints it.
778
855
 
856
+ The same replay says which workers to declare next time
857
+ (`TaskExecutor.advice`), each from an empty run with the plan's own
858
+ rules (the fewest that end it within its margin of the soonest): `best`
859
+ is how many of each size; `more`, shown only when more than a size's
860
+ `max` would end the run sooner past that margin, is how many it would
861
+ take; `any` is one shape for each platform, of 2 to 64 cores (memory a
862
+ core as the platform's sizes have most), the one ending as soon for the
863
+ least core time. Raise a size's `max`, or change its `cpu` and
864
+ `memory`, by them. They are in the footer and the run summary, and
865
+ `@vzn/vx-ci` prints them under "Workers to declare".
866
+
779
867
  ## Environment
780
868
 
781
869
  What `@vzn/vx-dte` reads from the CI job it runs in:
@@ -809,7 +897,12 @@ What `@vzn/vx-dte` reads from the CI job it runs in:
809
897
  process.env.CF_ACCESS_CLIENT_ID, … }`); an unset value is left out, and
810
898
  a name the dial sets itself (`authorization`, `content-type`,
811
899
  `relay-proof`) is refused. The rest:
812
- - `workers` names each `WorkerSize`, `{ cpu, memory, max?, platform? }`: its cores
900
+ - `workers` names each worker type, a `WorkerType` `{ machines }`, in order of
901
+ preference (`exec.worker` picks one). Each machine is a size the
902
+ adapter may start, named `<type>.<machine>` (`linux.small`) in the
903
+ run's output and an adapter's options, and is a `WorkerSize`,
904
+ `{ cpu, memory, max?, platform?, runsOn? }` (`runsOn`: the runner
905
+ label `vx dte init` writes for it): its cores
813
906
  and memory (bytes, or `'8GB'`, `'512MiB'`), which the run counts on
814
907
  when it starts one, until the worker reports what its machine has,
815
908
  how many of it one run may start (absent, no cap), and its machines'
@@ -817,16 +910,28 @@ process.env.CF_ACCESS_CLIENT_ID, … }`); an unset value is left out, and
817
910
  absent, planned as this machine's and not checked on join). With no adapter, the sizes' platforms are
818
911
  the ones joining workers may have. Only work counts:
819
912
  a task that missed the cache, or waits on a cached one that did, and
820
- every uncached task, which runs every time; groups and hits need no worker. At each miss, finish and join the run plays
913
+ every uncached task, which runs every time; groups and hits need no worker. The run plays
821
914
  that work out on its workers and on N more (`src/planner.ts`), each new
822
915
  one joining after its size's warm-up: this run's first of that size,
823
916
  else an earlier run's, else any size's this run, else 20 s,
824
917
  and takes the fewest N that end within 5% (5 s at least) of the best.
918
+ It plays only two warm-ups and the longest task ahead; past that, the
919
+ rest ends no sooner than its longest chain, nor than its work spread
920
+ on the cores it has, and the next plan, a warm-up on, sees further.
825
921
  Sizes of different platforms come in turns, so of those N it drops
826
922
  each the plan ends as soon without, its platform's work no more than
827
923
  5 s later.
828
924
  That margin is spent once: later plans keep its deadline until new
829
925
  work is known. A task waiting on one that runs waits until it ends.
926
+ Then the run follows that plan: a start it wants later is asked when
927
+ due, and a worker idle with its planned work done goes, once every
928
+ task planned on it has started. It plays again
929
+ only when reality leaves the plan: new work, work that leaves without
930
+ running, a worker idle at its planned end with a task planned on it not
931
+ started, a worker that joins, leaves or warms up off plan, a task past
932
+ its time or ending sooner, one of no known time ending, a task starting
933
+ with its time, CPU or memory more than 5% off (1 s, 0.05 cores, 64 MiB
934
+ at least), or its window moving on.
830
935
  What is left, running tasks too, takes as many times its history as
831
936
  the run's finished tasks took theirs (once they had 20 s of it; never
832
937
  less): a task past its forecast would otherwise be planned to end now,
@@ -876,7 +981,7 @@ HEAD^`), and every task after one (`src/forecast.ts`): a lockfile or
876
981
  - `adapter` is the `DteAdapter` that starts workers, of the sizes in
877
982
  `workers`: at least one with `max` above 0, or `dte()` is refused.
878
983
  Absent, the run starts none and uses the workers that join `relay`.
879
- - `relay` is the relay's URL, http(s); absent, the adapter's own.
984
+ - `relay` is the relay's base URL, http(s), without `/v1`, `?query` or `#fragment`; absent, the adapter's own.
880
985
  - `token` answers the relay's bearer token (default `VX_DTE_TOKEN`). It
881
986
  is asked again a minute before the token's `exp`, when it has one.
882
987
  - `unmeasured`, `{ cpu, memory }` (default 2 cores, `'4GB'`), is what a
@@ -907,7 +1012,7 @@ HEAD^`), and every task after one (`src/forecast.ts`): a lockfile or
907
1012
  - `DteAdapter` is `{ spawn(worker: WorkerSpawn), relay?(), close?(), cancel?(ids), init?(setup: DteSetup), runId?(), adopt?(worker), report?(warn), keyless?, workerRuns?() }`; `init` gets a `DteSetup` (workspace root, its directory in the repository, relay, sizes, the `PackageManager` read from the lockfile, the tasks asked for) and returns a `DteInit`: the CI files `vx dte init` writes and the steps it prints; `report` hands it the run's warning channel as the run starts, where `github()` says a status poll or a cancel the CI API refused, by status and body; `adopt` hands each worker the CI started before the run to `worker(id, size)`, which counts it and returns its spawn; `cancel` stops workers the run no longer needs that have not joined, and may answer with those it left running, which the run takes (with `late`, workers given up on joining, stopped whatever their job holds); `runId` names the run, in the adapter's own CI, when `VX_DTE_RUN` is unset; `workerId` names a worker in a job its CI started for several, when `VX_DTE_WORKER` is unset; `keyless` workers prove themselves by GitHub OIDC, and `workerRuns` names the workflow runs whose jobs alone may join as them (`undefined`: any job of the repository).
908
1013
  `WorkerSpawn` is `{ run, relay, size, max, cpu, memory, index, id, workspaceRoot, commit, cacheDir?, env }`,
909
1014
  with `memory` in bytes and `max` its size's (`Infinity` uncapped),
910
- where `id` is the worker's name (`small#2`, numbered within its size), `commit` the
1015
+ where `id` is the worker's name (`linux.small#2`, numbered within its size), `commit` the
911
1016
  tracked files as the run saw them at its start (uncommitted changes included), whose
912
1017
  tree a worker must check out, `cacheDir` the run's cache dir (absolute) when the
913
1018
  workspace names one, for workers on this machine to save where the run reads, and
@@ -916,10 +1021,9 @@ HEAD^`), and every task after one (`src/forecast.ts`): a lockfile or
916
1021
  of its own on loopback. `DockerOptions` is `{ image, prepare? }`.
917
1022
  - `github(options?: GithubOptions)` dispatches workers as matrix jobs of
918
1023
  one workflow, one dispatch per size and demand (at most 256 workers,
919
- a matrix's limit). `GithubOptions` is `{ workflow?, runsOn?, env? }`:
1024
+ a matrix's limit). `GithubOptions` is `{ workflow?, env? }`:
920
1025
  `workflow` defaults to `vx-worker.yml`, a file name in
921
- `.github/workflows` other than `ci.yml`, the run's own; `runsOn` is the runner label
922
- of each size, which `vx dte init` writes; `env` is variables every
1026
+ `.github/workflows` other than `ci.yml`, the run's own; `env` is variables every
923
1027
  worker gets (one-line strings, no secret-named one: the worker job's
924
1028
  log shows them), sent as the
925
1029
  dispatch's `env` input only when set, which `vx dte init`'s workflow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-dte",
3
- "version": "0.0.651",
3
+ "version": "0.0.653",
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.651"
32
+ "@vzn/vx-dte-server": "0.0.653"
33
33
  },
34
34
  "peerDependencies": {
35
- "@vzn/vx": "^0.0.651"
35
+ "@vzn/vx": "^0.0.653"
36
36
  },
37
37
  "repository": {
38
38
  "type": "git",