@vzn/vx-dte 0.0.652 → 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
@@ -529,7 +559,9 @@ use your own instead.
529
559
  deep graph's orders stay small.
530
560
  - A task runs with the orchestrator's environment under the worker's
531
561
  root. `HOME`, `TMPDIR`, `USER` and the like are the worker's own, unless
532
- 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
533
565
  package manager on the worker, as core sets it on a local task.
534
566
  - A worker restores, by key, the declared outputs of every task the one
535
567
  it runs depends on, each after its own dependencies (an additive task's
@@ -540,7 +572,9 @@ use your own instead.
540
572
  leaves no file. One waits there up to 10 s; past that, with that worker
541
573
  gone or of another platform, or with its uncached upstreams on two
542
574
  workers, the upstream runs again where the dependant can go, and the
543
- 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
544
578
  tries (too big, a 429, a reset connection) is its worker's fault: that
545
579
  worker takes no more tasks and the task runs on another; a second fault
546
580
  fails the run. Before a task runs, its
@@ -562,20 +596,37 @@ use your own instead.
562
596
  `--no-cache` or read-only run, a dependant of a failure under
563
597
  `--continue=always`, one that left a process running that may still
564
598
  write its outputs, one a stopped run cancelled), and one that left a tracked file under its project
565
- changed on its worker (a formatter's input), which this machine never
566
- 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.
567
603
  - Under `--force`, a success is saved under its key (which keeps an entry
568
604
  it already holds, as core does) and under the run's key, which this
569
605
  machine and the dependants' workers restore: the bytes this run built,
570
606
  never the entry it bypassed.
571
607
  - A worker whose tracked files differ from the orchestrator's (another
572
608
  tree, or the same commit with a tracked file changed, such as by a
573
- prepare step) is refused. Both sides read their tracked files as they
574
- 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.
575
623
  - A task whose key hashes a file the run's commit lacks (untracked, or
576
624
  gitignored and named outright, or inside a submodule, whose files the
577
625
  commit holds only as a gitlink) fails, naming the file: no worker has it.
578
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.
579
630
  - A task's `exec.timeout` counts from its start on its worker: the wait
580
631
  for a worker still booting is not its run (the executor declares
581
632
  `timeouts`).
@@ -592,8 +643,14 @@ use your own instead.
592
643
  - A relay that answers nothing for 10 s, every try counted, fails every task not done
593
644
  (`the relay is unreachable`); none runs here, which may be far smaller
594
645
  than the workers. What finished is saved, so a rerun resumes from it.
595
- A shorter fault (a `5xx` while the host restarts the relay, a dropped
596
- 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.
597
654
  - The relay stores nothing, so a restart forgets the run. The orchestrator
598
655
  joins again as its host and each worker joins again as itself, for two
599
656
  heartbeats (4 s on the default); tasks keep running meanwhile, and what
@@ -601,9 +658,12 @@ use your own instead.
601
658
  keeps what it runs, and one sent a task it runs already
602
659
  does not run it again, so no work repeats. One not back by then
603
660
  ends: a worker's tasks run elsewhere, an orchestrator's run fails.
604
- 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
605
664
  relay silent that long fails the run with `cannot connect to the relay
606
- <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`.
607
667
  - A worker that never joins says why on its row (`failed exited 1:
608
668
  …`); its job ending, a success too, is what says so. Once the first
609
669
  worker of a size joins, each other of that size not joined within twice
@@ -620,8 +680,8 @@ use your own instead.
620
680
  - Only `leave` ends a participant at once (2026-10-25): the
621
681
  orchestrator's at its run's end is the final call that closes the run,
622
682
  and a worker's when it is done. Either whose socket drops without it
623
- dials again, 3 times within 10 s, and the relay keeps it, and its run,
624
- 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
625
685
  from the other for 3 heartbeats (6 s on the default) loses it, and the
626
686
  rest follows from that: a worker that said nothing for half of it (3 s)
627
687
  sends its report; the orchestrator never speaks on a timer, only orders
@@ -664,14 +724,16 @@ on its frame, in that worker's own color, by size and the order the run
664
724
  started it:
665
725
 
666
726
  ```
667
- ⏺︎ 308ms success miss c#build · small#1
727
+ ⏺︎ 308ms success miss c#build · linux.small#1
668
728
  ```
669
729
 
670
730
  An idle worker the rest of the run cannot keep busy leaves (`left`): one
671
731
  beyond the peak of the cores the remaining tasks keep busy, so a lull
672
732
  between two wide parts keeps it. One a remaining task needs for its size
673
733
  stays, and so does one a later task needs when its size's `max` is
674
- 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
675
737
  another, and the run starts one in its place. A worker is lost when its
676
738
  socket closes, when it says nothing for 3 heartbeats (6 s), or at
677
739
  once when its adapter sees its process or job end. Workers start at the first
@@ -684,7 +746,7 @@ workspace's cache under its commit, and the next commit's run plans by it
684
746
  before any worker of that size joins.
685
747
 
686
748
  The `workers` rows, live while the run goes and again in the footer,
687
- give each worker its status (`starting`, `online`, `left`, `gone`, or `failed`
749
+ give each worker its status (`starting`, `online`, `left`, `lost`, or `failed`
688
750
  with why, such as `exited 1: Cannot find package '@vzn/vx-dte'`; once the
689
751
  run is over, one still starting is `not joined`), the tasks it
690
752
  has done, failed or had aborted by a stop, and the ones it runs now; while the run goes, those that left
@@ -702,28 +764,32 @@ declares and its machine reports, green when they agree and red when
702
764
  not:
703
765
 
704
766
  ```
705
- workers small#1 online 3 done · 2 running
706
- small#2 starting
767
+ workers linux.small#1 online 3 done · 2 running
768
+ linux.small#2 starting
707
769
  local online 1 done
708
770
  ```
709
771
 
710
772
  ```
711
- workers small#1 stopped 5 done · warm-up 0.1s
773
+ workers linux.small#1 stopped 5 done · warm-up 0.1s
712
774
  declared 2c/8G · actual 4c/16G
713
- small#2 lost 1 done · warm-up 0.2s
775
+ linux.small#2 lost 1 done · warm-up 0.2s
714
776
  declared 2c/8G · actual 4c/16G
715
777
  local online 1 done
716
778
  actual 10c/32G
717
779
  ```
718
780
 
719
781
  A run over that started none (every task a hit) says
720
- `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>)`.
721
786
 
722
787
  Under each worker, and `local`, the footer then spells out what it did:
723
788
  its time online, busy and idle; its CPU peak and average in cores and
724
789
  the cores the run booked for the tasks it was running, on average (a
725
- booked from a task's start to its command's end); its cpu utilization (the core-time its
726
- 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
727
793
  bytes the run sent it and received from it. The `probe` line says when
728
794
  core's cache probe answered, how many tasks it left to run (held in no
729
795
  cache, or not answered for) and how many of them it found in none. The `asked` line says each
@@ -740,23 +806,24 @@ the worker started, any process's, as its last frame said. Where the
740
806
  rest went, the worker says in its own log as it leaves: `cpu over 61.2s:
741
807
  machine 2.80 cores · tasks 2.00 cores · this worker 0.15 cores · its other
742
808
  children 0.05 cores · most besides: Runner.Worker 0.30 cores`, the last
743
- 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
744
810
  machine together, and the share of their history the run's tasks used:
745
811
 
746
812
  ```
747
- small#1 stopped 5 done · warm-up 0.2s
813
+ linux.small#1 stopped 5 done · warm-up 0.2s
748
814
  declared 4c/14G · actual 4c/14G
749
815
  online 12.1s · busy 9.8s · idle 2.3s
750
- 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%
751
818
  memory peak 1.2 GB · avg 640.0 MB
752
819
  sent 6 messages · 3.1 MB, received 15 messages · 8.4 MB
753
820
  probe at 0.4s: 5 tasks left to run, 5 known misses
754
821
  asked 1 at 0.5s for 5 tasks (5 measured), plan 12.0s
755
822
  ideal 10.8s: each task as long as it took, placed perfectly
756
- best 2 × small (4c/14G): 11.4s, the fewest of its sizes
757
- more 3 × small (4c/14G): 10.8s, past its sizes' max
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
758
825
  any 1 × (8c/28G): 10.9s, of any cores, the least core time
759
- total cpu utilization 58%
826
+ total cpu utilization machine 74% · tasks 60% · worker 2%
760
827
  ```
761
828
 
762
829
  `vx run --profile[=<path>]` adds the run in time to the profile it
@@ -830,7 +897,12 @@ What `@vzn/vx-dte` reads from the CI job it runs in:
830
897
  process.env.CF_ACCESS_CLIENT_ID, … }`); an unset value is left out, and
831
898
  a name the dial sets itself (`authorization`, `content-type`,
832
899
  `relay-proof`) is refused. The rest:
833
- - `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
834
906
  and memory (bytes, or `'8GB'`, `'512MiB'`), which the run counts on
835
907
  when it starts one, until the worker reports what its machine has,
836
908
  how many of it one run may start (absent, no cap), and its machines'
@@ -852,9 +924,11 @@ process.env.CF_ACCESS_CLIENT_ID, … }`); an unset value is left out, and
852
924
  That margin is spent once: later plans keep its deadline until new
853
925
  work is known. A task waiting on one that runs waits until it ends.
854
926
  Then the run follows that plan: a start it wants later is asked when
855
- due, and a worker idle with its planned work done goes. It plays again
927
+ due, and a worker idle with its planned work done goes, once every
928
+ task planned on it has started. It plays again
856
929
  only when reality leaves the plan: new work, work that leaves without
857
- running, a worker that joins, leaves or warms up off plan, a task past
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
858
932
  its time or ending sooner, one of no known time ending, a task starting
859
933
  with its time, CPU or memory more than 5% off (1 s, 0.05 cores, 64 MiB
860
934
  at least), or its window moving on.
@@ -938,7 +1012,7 @@ HEAD^`), and every task after one (`src/forecast.ts`): a lockfile or
938
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).
939
1013
  `WorkerSpawn` is `{ run, relay, size, max, cpu, memory, index, id, workspaceRoot, commit, cacheDir?, env }`,
940
1014
  with `memory` in bytes and `max` its size's (`Infinity` uncapped),
941
- 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
942
1016
  tracked files as the run saw them at its start (uncommitted changes included), whose
943
1017
  tree a worker must check out, `cacheDir` the run's cache dir (absolute) when the
944
1018
  workspace names one, for workers on this machine to save where the run reads, and
@@ -947,10 +1021,9 @@ HEAD^`), and every task after one (`src/forecast.ts`): a lockfile or
947
1021
  of its own on loopback. `DockerOptions` is `{ image, prepare? }`.
948
1022
  - `github(options?: GithubOptions)` dispatches workers as matrix jobs of
949
1023
  one workflow, one dispatch per size and demand (at most 256 workers,
950
- a matrix's limit). `GithubOptions` is `{ workflow?, runsOn?, env? }`:
1024
+ a matrix's limit). `GithubOptions` is `{ workflow?, env? }`:
951
1025
  `workflow` defaults to `vx-worker.yml`, a file name in
952
- `.github/workflows` other than `ci.yml`, the run's own; `runsOn` is the runner label
953
- 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
954
1027
  worker gets (one-line strings, no secret-named one: the worker job's
955
1028
  log shows them), sent as the
956
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.652",
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.652"
32
+ "@vzn/vx-dte-server": "0.0.653"
33
33
  },
34
34
  "peerDependencies": {
35
- "@vzn/vx": "^0.0.652"
35
+ "@vzn/vx": "^0.0.653"
36
36
  },
37
37
  "repository": {
38
38
  "type": "git",
package/src/dispatch.ts CHANGED
@@ -157,6 +157,12 @@ export interface Need {
157
157
  * free core, but a run of only such tasks still asks enough workers.
158
158
  */
159
159
  readonly ask?: { readonly cores: number; readonly memory: number } | undefined
160
+ /**
161
+ * Its persistent dependencies' bases, cores (`server-fit.ts`): a worker
162
+ * not running one yet books its base once beside the task, and keeps it
163
+ * booked while it serves.
164
+ */
165
+ readonly servers?: readonly { readonly id: string; readonly cores: number }[] | undefined
160
166
  }
161
167
 
162
168
  /** A worker as dispatch sees it: its size and what is in use on it now. */
@@ -172,6 +178,8 @@ export interface Bin {
172
178
  readonly has?: ReadonlySet<string>
173
179
  /** Its machine's platform (`linux-x64`), as it reported or its size declares. */
174
180
  readonly platform?: string | undefined
181
+ /** The servers it runs, each base booked: a task needing one books no base here. */
182
+ readonly serving?: Set<string>
175
183
  }
176
184
 
177
185
  export interface Ask {
@@ -193,7 +201,14 @@ export interface Ask {
193
201
 
194
202
  /** What `Ask.kind` names: every field of a need `assign` reads. */
195
203
  export function kindOf(need: Need): string {
196
- return `${need.cores}\0${need.memory}\0${need.above?.cores}\0${need.above?.memory}\0${need.platform}`
204
+ return `${need.cores}\0${need.memory}\0${need.above?.cores}\0${need.above?.memory}\0${need.platform}\0${need.servers?.map((s) => s.id).join('\0')}`
205
+ }
206
+
207
+ /** The cores `need` books on `bin`: its own, and the base of each server `bin` does not run yet. */
208
+ function coresOn(bin: Bin, need: Need): number {
209
+ let cores = need.cores
210
+ for (const s of need.servers ?? []) if (bin.serving?.has(s.id) !== true) cores += s.cores
211
+ return cores
197
212
  }
198
213
 
199
214
  /**
@@ -204,9 +219,10 @@ export function kindOf(need: Need): string {
204
219
  export function fits(bin: Bin, need: Need): boolean {
205
220
  if (need.platform !== undefined && bin.platform !== need.platform) return false
206
221
  if (bin.running === 0) return true
222
+ const cores = coresOn(bin, need)
207
223
  return (
208
- need.cores < bin.cores &&
209
- bin.used.cores + need.cores <= bin.cores + 1e-9 &&
224
+ cores < bin.cores &&
225
+ bin.used.cores + cores <= bin.cores + 1e-9 &&
210
226
  bin.used.memory + need.memory <= bin.memory
211
227
  )
212
228
  }
@@ -313,7 +329,8 @@ export function assign<B extends Bin>(
313
329
  }
314
330
  spent.clear()
315
331
  out.set(ask.id, best)
316
- best.used.cores += Math.min(need.cores, best.cores)
332
+ best.used.cores += Math.min(coresOn(best, need), best.cores)
333
+ for (const sv of need.servers ?? []) best.serving?.add(sv.id)
317
334
  best.used.memory += Math.min(need.memory, best.memory)
318
335
  best.running++
319
336
  rebound()
@@ -486,6 +503,24 @@ export class Queue {
486
503
  if (!front) this.back = seq
487
504
  }
488
505
 
506
+ /**
507
+ * History changed (`tail`, `need`): the waiting rank and group by it,
508
+ * ties in the order they stood. A rank is read once, as a task joins:
509
+ * on CI the roots join before the default branch's timings land, each
510
+ * at an unknown task's 30 s, and every test shard ready later ranked
511
+ * above them, so `test.wire` (73 s) waited 129 s for a worker (main
512
+ * 1c91c9d, 2026-11-07). `graphene` reads ranks at each pass.
513
+ */
514
+ rerank(): void {
515
+ if (this.list !== undefined) return
516
+ const ids = this.ids()
517
+ this.groups.clear()
518
+ this.of.clear()
519
+ this.front = 0
520
+ this.back = 0
521
+ this.add(ids)
522
+ }
523
+
489
524
  delete(id: string): boolean {
490
525
  if (this.list !== undefined) {
491
526
  const at = this.list.indexOf(id)
package/src/github.ts CHANGED
@@ -14,8 +14,6 @@ import { printable } from './printable.js'
14
14
  export interface GithubOptions {
15
15
  /** The workers' workflow file in `.github/workflows` (default `vx-worker.yml`). */
16
16
  readonly workflow?: string
17
- /** The runner label of each size, by size name, which `vx dte init` writes; absent, its platform's GitHub-hosted runner. */
18
- readonly runsOn?: Readonly<Record<string, string>>
19
17
  /**
20
18
  * Variables the run hands its workers, as the workflow's `env` input (a
21
19
  * JSON object): a value the run's job decides, such as a server's URL
@@ -33,7 +31,6 @@ interface Job {
33
31
 
34
32
  const KEYS: PluginOptionKinds<GithubOptions> = {
35
33
  workflow: 'string',
36
- runsOn: 'object',
37
34
  env: 'object',
38
35
  }
39
36
 
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  dte,
3
3
  type WorkerSize,
4
+ type WorkerType,
4
5
  type WorkerSpawn,
5
6
  type DteAdapter,
6
7
  type DteOptions,
package/src/init.ts CHANGED
@@ -25,6 +25,8 @@ export interface DteSetup {
25
25
  /** Absent: as many as the work needs. */
26
26
  readonly max?: number
27
27
  readonly platform?: Platform
28
+ /** The runner label its machine names (`WorkerSize.runsOn`). */
29
+ readonly runsOn?: string
28
30
  }[]
29
31
  /** The workspace's package manager, by its lockfile. */
30
32
  readonly manager: PackageManager
@@ -45,12 +47,14 @@ export interface DteInit {
45
47
  /** `owner/repo` of the `origin` remote and its host: what the relay's ALLOW names. */
46
48
  export function originOf(root: string): { repository?: string; host?: string } {
47
49
  const r = Bun.spawnSync(['git', 'config', '--get', 'remote.origin.url'], { cwd: root })
48
- const url = r.exitCode === 0 ? r.stdout.toString().trim() : ''
50
+ // No credential reaches the written files: a query (`?private_token=`)
51
+ // is dropped, and a URL's userinfo ends at its last `@` (a password may hold one).
52
+ const url = (r.exitCode === 0 ? r.stdout.toString().trim() : '').replace(/[?#].*$/s, '')
49
53
  const m = /[:/]([^:]+?\/[^/]+?)(?:\.git)?\/?$/.exec(url.replace(/^[a-z+]+:\/\/[^/]+\//, '/'))
50
54
  // An https URL's port is its issuer's; an ssh one's is not.
51
55
  const host =
52
- /^https?:\/\/(?:[^@/]*@)?([^/]+)/.exec(url)?.[1] ??
53
- /^[a-z+]+:\/\/(?:[^@/]*@)?([^/:]+)/.exec(url)?.[1] ??
56
+ /^https?:\/\/(?:[^/]*@)?([^/]+)/.exec(url)?.[1] ??
57
+ /^[a-z+]+:\/\/(?:[^/]*@)?([^/:]+)/.exec(url)?.[1] ??
54
58
  /^(?:[^@/:]*@)?([^/:]+):/.exec(url)?.[1]
55
59
  return {
56
60
  ...(m?.[1] === undefined ? {} : { repository: m[1] }),
@@ -249,12 +253,7 @@ const RUNNERS: Record<Platform, string> = {
249
253
  export function githubInit(options: GithubOptions, setup: DteSetup): DteInit {
250
254
  const workflow = options.workflow ?? 'vx-worker.yml'
251
255
  const runners = Object.fromEntries(
252
- setup.sizes.map((s) => {
253
- const label: unknown = options.runsOn?.[s.name]
254
- if (label !== undefined && (typeof label !== 'string' || label === ''))
255
- throw new UserError(`vx dte init: github() runsOn.${s.name} is a runner label`)
256
- return [s.name, label ?? RUNNERS[s.platform ?? 'linux-x64']]
257
- }),
256
+ setup.sizes.map((s) => [s.name, s.runsOn ?? RUNNERS[s.platform ?? 'linux-x64']]),
258
257
  )
259
258
  // A workspace in a subdirectory: CI files live at the repository's root,
260
259
  // and their commands run in the workspace.
@@ -448,7 +447,7 @@ export function gitlabInit(options: GitlabOptions, setup: DteSetup): DteInit {
448
447
  // A matrix takes at most 200 jobs; a wave picks its slots.
449
448
  if (s.max === undefined || s.max < 1 || s.max > 200)
450
449
  throw new UserError(
451
- `vx dte init: gitlab() needs workers.${s.name}.max, 1 to 200: its job is a matrix of that many slots`,
450
+ `vx dte init: gitlab() needs workers.${s.name.replace('.', '.machines.')}.max, 1 to 200: its job is a matrix of that many slots`,
452
451
  )
453
452
  const slots = Array.from({ length: s.max }, (_, i) => quoted(String(i + 1)))
454
453
  return `