@vzn/vx-dte 0.0.0 → 0.0.649

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
@@ -1,3 +1,925 @@
1
1
  # @vzn/vx-dte
2
2
 
3
- Placeholder. Part of [vx](https://github.com/vznjs/vx).
3
+ Website: <https://vznjs.github.io/vx/> · source: <https://github.com/vznjs/vx>
4
+
5
+ Distributed task execution for vx: one `vx run` spread over workers your
6
+ own CI starts, replayed here as one run. The run you start is the
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
9
+ output and the files its declared outputs left to the workspace's cache,
10
+ under the task's key. They land here before the task ends, so the run
11
+ reports the task as its own. No file travels through the relay: it
12
+ carries only orders and status, so the workers and this machine need one
13
+ cache they all read (a remote the workers write, or one shared store). A worker reads that cache as the run does:
14
+ under the run's refs, with the short-lived access the run's remote lends
15
+ it (an OIDC exchange's token, never a stored secret) when it has none of
16
+ its own.
17
+
18
+ Workers and the orchestrator meet at a relay
19
+ ([`@vzn/vx-dte-server`](../vx-dte-server/README.md)), since CI runners
20
+ take no inbound connection. It speaks the
21
+ [Switchboard protocol](../vx-dte-server/PROTOCOL.md): the run is a
22
+ session, its id the run id, the orchestrator its host and each worker a
23
+ peer. Host your own: its
24
+ [README](../vx-dte-server/README.md) has a Deploy to Cloudflare button,
25
+ which asks only for the repositories whose runs it carries.
26
+
27
+ A run id is a channel. Workers join it before or after the orchestrator
28
+ does, and the orchestrator claims it with a key made for that run; the
29
+ relay keeps the public half and passes nothing to or from a side that
30
+ cannot sign by it. The orchestrator checks each worker the moment it
31
+ appears and removes any without the run's key from the channel.
32
+
33
+ The relay reads nothing they say. Each worker and the orchestrator run a
34
+ [Noise](https://noiseprotocol.org/noise.html) handshake through it, one
35
+ session per worker, with keys made for that session only. With the run's
36
+ key (`VX_DTE_SECRET`, or one an adapter made for its workers) it is `Noise_NNpsk0_25519_AESGCM_SHA256`: the key is the
37
+ pre-shared key, so a side without it cannot finish the handshake, and the
38
+ worker's `join` info (cores, memory, tree) is already encrypted. On GitHub
39
+ Actions with no key it is `Noise_NN_25519_AESGCM_SHA256`, and each side
40
+ binds the handshake by an OIDC token of this repository (`id-token:
41
+ write`): no secret is stored anywhere. With no adapter and no GitHub, set
42
+ the same `VX_DTE_SECRET` on the run and each worker. A message the relay
43
+ changes loses its worker; one it replays is dropped. The key never
44
+ encrypts a message, so a leaked one opens no past run.
45
+
46
+ It runs workers with `local()`, `docker()`, `github()` and `gitlab()`.
47
+
48
+ ### Bodies
49
+
50
+ What vx-dte puts in Switchboard's `info` and `body`, for anyone writing a
51
+ worker or orchestrator of their own. Each worker and the orchestrator run
52
+ a Noise handshake through the relay, prologue `vx-dte\0<run>`, the worker
53
+ the initiator.
54
+
55
+ - With the run's key: `Noise_NNpsk0_25519_AESGCM_SHA256`, the psk the
56
+ key's SHA-256.
57
+ - Keyless on GitHub Actions: `Noise_NN_25519_AESGCM_SHA256`. The worker's
58
+ `join` carries an OIDC token whose audience is
59
+ `vx-dte:<base64url SHA-256 of "join\0<run>\0<message 1>">`; the
60
+ orchestrator's `hello` one for `"hello\0<run>\0<handshake hash>"`. Each
61
+ side checks the other's as a token of the same `repository_id`; with
62
+ `github()`, the orchestrator also checks a worker's `run_id` is a
63
+ workflow run it dispatched, since the run's id is a dispatch input
64
+ anyone who reads the repository's Actions sees. A dispatch GitHub
65
+ answers without a run id (204) is refused, so its workers exit.
66
+
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>", …] }` |
73
+
74
+ The orchestrator checks each worker the moment the relay says it joined,
75
+ and `remove`s one whose handshake fails. Message 1's payload is the
76
+ worker's info (JSON), encrypted under the psk. After the handshake every
77
+ body is Noise transport messages, base64, each at most 65535 bytes and
78
+ naming its nonce `n` (Noise §11.4, since a relay may reorder); each is
79
+ sealed with associated data `<first n>.<parts>` of its body, so no part
80
+ 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
+ replay and is dropped; one older than a body already opened (the relay
83
+ 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
+ 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
90
+ within a class. Inside, each send is numbered and acknowledged
91
+ end to end, and a quiet peer probed (Switchboard § Delivery, § Liveness).
92
+
93
+ ## Try it
94
+
95
+ ```sh
96
+ bun add -d @vzn/vx-dte
97
+ ```
98
+
99
+ ```ts
100
+ // .vx/workspace.ts
101
+ import { defineWorkspace } from '@vzn/vx/config'
102
+ import { dte, local } from '@vzn/vx-dte'
103
+
104
+ export default defineWorkspace({
105
+ plugins: [
106
+ dte({
107
+ workers: { small: { max: 4, cpu: 2, memory: '8GB' } },
108
+ adapter: local({ prepare: 'bun install' }),
109
+ }),
110
+ ],
111
+ })
112
+ ```
113
+
114
+ ```sh
115
+ VX_DTE_RUN=$(uuidgen) vx run build --all
116
+ ```
117
+
118
+ `VX_DTE_RUN` names the run and turns distribution on; without it, `vx run`
119
+ runs here as always (on GitHub Actions, `github()` with a relay names one
120
+ itself). It is a random UUID, so no one can guess a run to
121
+ open it first or join it; vx and the relay refuse any other. `local()` starts each worker as a process in its own
122
+ 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
125
+ workspace's place in it (a workspace in a subdirectory of its repository too),
126
+ saving to the run's store (a relative `cache.dir` or `--cache-dir` too).
127
+ A run from a git hook works too: the worktree and its worker drop the
128
+ hook's repository variables (`git rev-parse --local-env-vars`), so the
129
+ index being committed is left as it is. When a worker fails, its row names
130
+ the error line and the log that holds its whole stderr and all `prepare`
131
+ printed. At the run's end,
132
+ 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.
134
+
135
+ `docker()` starts each worker as a container held to its size: `--cpus`
136
+ and `--memory` come from `cpu` and `memory`. The container clones this
137
+ checkout (mounted read-only) into its own filesystem at its tracked
138
+ files as they are, uncommitted changes too, runs
139
+ `prepare`, then the workspace's own `node_modules/.bin/vx dte worker`, so
140
+ the image needs Bun and git, and `prepare` installs vx. This machine's store
141
+ (`~/.vx`) and a cache dir the workspace names (`cache.dir`, `VX_CACHE_DIR`, `--cache-dir`)
142
+ are mounted at their own paths, and the
143
+ clone keeps this checkout's `origin`, so the workers save where the run
144
+ reads, as `local()`'s do; no remote is needed. On Linux a container runs
145
+ as this user (its home is `/tmp`, where the store is mounted), so what it
146
+ writes is this user's and git trusts the mounted checkout. A container that fails
147
+ names its error line and the log of its whole stderr and all `prepare` printed:
148
+
149
+ ```ts
150
+ dte({
151
+ workers: { small: { max: 4, cpu: 2, memory: '4GB' } },
152
+ adapter: docker({ image: 'my/bun-git', prepare: 'bun install' }),
153
+ })
154
+ ```
155
+
156
+ The relay stays on loopback: on Linux a container joins the host's
157
+ network; elsewhere it reaches the relay through `host.docker.internal`.
158
+ A container ends with its worker; one still up when the run ends is
159
+ removed.
160
+
161
+ With no adapter, vx starts no workers: the run uses whichever join its
162
+ relay. In three terminals on one checkout's commit:
163
+
164
+ ```ts
165
+ // .vx/workspace.ts
166
+ export default defineWorkspace({ plugins: [dte({ relay: 'http://127.0.0.1:8080' })] })
167
+ ```
168
+
169
+ ```sh
170
+ bun /path/to/vx/packages/vx-dte-server/src/bun.ts --no-auth --port 8080 # the relay
171
+ export VX_DTE_SECRET=$(openssl rand -base64 32) # in each terminal: the same one
172
+ export VX_DTE_RUN=$(uuidgen) # in each terminal: the same one
173
+ vx run build --all # the run, first
174
+ vx dte worker # a worker, as many as you like
175
+ ```
176
+
177
+ A worker started before its run finds no run on the relay: it says so
178
+ and exits.
179
+
180
+ A worker prints its name, the run, the relay and its machine, then a row
181
+ as each task starts and ends:
182
+
183
+ ```
184
+ ─ vx dte worker ───────────────────────────────────────────────
185
+ worker worker#1
186
+ run 5f0c7a52-…
187
+ relay http://127.0.0.1:8080
188
+ machine 10 cores · 17.2 GB
189
+ › b#build running
190
+ ✓ b#build 308ms
191
+ ```
192
+
193
+ ## On GitHub
194
+
195
+ The run is your own job's `vx run`. When it needs workers it dispatches
196
+ `.github/workflows/vx-worker.yml` once per size, with the workers of that
197
+ size as a matrix, each job named for its worker (`name: ${{ matrix.worker }}`):
198
+ a job that ends badly before its worker joins marks that worker `failed`
199
+ with the step it failed at, and the run starts another in its place. When the run ends it cancels each worker run it
200
+ dispatched, so one still queued does not hold a runner the next run
201
+ waits for. Every job of a repository shares its `GITHUB_TOKEN`'s API
202
+ limit, 1000 requests an hour, so the run reads its workers' jobs at most
203
+ every 30 s, asks with each answer's ETag (an unchanged answer is free),
204
+ and asks nothing until a spent limit resets. A dispatch the limit refuses
205
+ is sent again at the reset, if that comes within 5 minutes; past that it
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
208
+ (a cancelled or timed-out job) leaves it to the job's last step, which
209
+ cancels each worker run the run listed in `RUNNER_TEMP`. Past both, a
210
+ worker that starts reads its run's attempt (`parent`) first, and once
211
+ that is over cancels its own workflow run, its queued fellows with it.
212
+ On GitHub Actions with a relay, the run names itself: no
213
+ `VX_DTE_RUN` to set. No secret: each side proves its key with the job's OIDC
214
+ token, and the relay's token is an OIDC token for the relay's URL. Such a
215
+ token lasts 5 minutes, so each side asks for a new one a minute before
216
+ its token expires.
217
+
218
+ ```ts
219
+ // .vx/workspace.ts
220
+ export default defineWorkspace({
221
+ plugins: [
222
+ dte({
223
+ relay: 'https://relay.example.com',
224
+ workers: { small: { max: 8, cpu: 4, memory: '16GB' } },
225
+ adapter: github(),
226
+ }),
227
+ ],
228
+ })
229
+ ```
230
+
231
+ ```yaml
232
+ # .github/workflows/ci.yml
233
+ # Written by vx dte init: the run. main and a merge queue run every task;
234
+ # a pull request runs what it changed and its dependents. This job only
235
+ # orchestrates: every task runs on a worker (vx-worker.yml).
236
+ name: ci
237
+
238
+ on:
239
+ pull_request:
240
+ merge_group:
241
+ push:
242
+ branches: [main]
243
+
244
+ # A newer push to a pull request cancels its older run, and its workers.
245
+ concurrency:
246
+ group: ci-${{ github.event.pull_request.number || github.ref }}
247
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
248
+
249
+ jobs:
250
+ vx:
251
+ runs-on: ubuntu-latest
252
+ timeout-minutes: 30
253
+ permissions:
254
+ contents: read
255
+ # The relay and the workers: each side proves itself by its OIDC token.
256
+ id-token: write
257
+ # Dispatching and cancelling the workers.
258
+ actions: write
259
+ env:
260
+ GITHUB_TOKEN: ${{ github.token }}
261
+ steps:
262
+ # One commit: --affected fetches its base itself.
263
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
264
+ with: { persist-credentials: false }
265
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
266
+ - run: bun install --frozen-lockfile --filter './'
267
+ - run: bun vx lock --check
268
+ # Name the tasks to run, as 'bun vx run build test --affected': with
269
+ # none named, vx fails, listing the workspace's tasks.
270
+ - run: bun vx run ${{ github.event_name == 'pull_request' && '--affected' || '--all' }}
271
+ - name: Cancel the workers
272
+ if: cancelled()
273
+ run: |
274
+ [ -f "$RUNNER_TEMP/vx-dte-runs" ] || exit 0
275
+ while read -r run; do curl -sS -o /dev/null -X POST -H "authorization: Bearer $GITHUB_TOKEN" "$GITHUB_API_URL$run/cancel" || true; done < "$RUNNER_TEMP/vx-dte-runs"
276
+ ```
277
+
278
+ `vx dte init` writes both from `dte()`, the run's above and the workers' below,
279
+ and prints what is left: a relay that admits them (its `ALLOW`, and how
280
+ to deploy one), a shared remote cache, the commit. In a terminal it asks
281
+ what main runs and what a pull request runs (main's by default); without
282
+ one it names none, and the run fails, listing the tasks, until you do. A
283
+ workspace in a subdirectory of its repository gets them at the repository's
284
+ root, its jobs running in that directory. Each size runs on
285
+ its platform's GitHub-hosted runner (`ubuntu-latest` for none named,
286
+ `ubuntu-24.04-arm`, `macos-latest`, `macos-15-intel`), or on the label
287
+ `github({ runsOn: { small: 'ubuntu-latest-16-cores' } })` names, as
288
+ `gitlab({ tags })` does. It installs with the package manager your lockfile
289
+ names and runs vx as `bun vx`, which runs the repo's own and never
290
+ downloads one, as `pnpm exec` does (Yarn too: Bun reads no Plug'n'Play,
291
+ so a Yarn workspace sets `nodeLinker: node-modules`). `--dry` prints it; a file you changed
292
+ is kept unless `--force`. For the sizes above:
293
+
294
+ ```yaml
295
+ # .github/workflows/vx-worker.yml
296
+ # Written by vx dte init from dte() in .vx/workspace.ts: the workers of a
297
+ # DTE run, which github() dispatches once per size. Run vx dte init
298
+ # again after changing the sizes.
299
+ name: vx-worker
300
+
301
+ on:
302
+ workflow_dispatch:
303
+ inputs:
304
+ run: { required: true }
305
+ relay: { required: true }
306
+ sha: { required: true }
307
+ size: { required: true }
308
+ workers: { required: true }
309
+ # The variables github({ env }) hands the workers, as a JSON object.
310
+ env: { required: false, default: '{}' }
311
+ # The run's attempt, which a worker reads first.
312
+ parent: { required: false, default: '' }
313
+
314
+ permissions:
315
+ contents: read
316
+ id-token: write
317
+
318
+ jobs:
319
+ worker:
320
+ # The run reads a job that failed before its worker joined by this name.
321
+ name: ${{ matrix.worker }}
322
+ # A job's permissions replace the workflow's, so it names all three.
323
+ permissions:
324
+ contents: read
325
+ id-token: write
326
+ # A worker of a run that is over cancels its own run.
327
+ actions: write
328
+ runs-on: ${{ fromJSON('{"small":"ubuntu-latest"}')[inputs.size] }}
329
+ timeout-minutes: 30
330
+ strategy:
331
+ fail-fast: false
332
+ matrix:
333
+ worker: ${{ fromJSON(inputs.workers) }}
334
+ env:
335
+ VX_DTE_RUN: ${{ inputs.run }}
336
+ VX_DTE_RELAY: ${{ inputs.relay }}
337
+ VX_DTE_WORKER: ${{ matrix.worker }}
338
+ steps:
339
+ - name: Leave if the run is over
340
+ if: inputs.parent != ''
341
+ env: { GH_TOKEN: '${{ github.token }}', PARENT: '${{ inputs.parent }}' }
342
+ run: |
343
+ runs="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs"
344
+ status=$(curl -fsS -H "authorization: Bearer $GH_TOKEN" "$runs/$PARENT" | jq -r .status) || exit 0
345
+ [ "$status" = completed ] || exit 0
346
+ echo "::notice::run $PARENT is over: cancelling this one"
347
+ curl -fsS -o /dev/null -X POST -H "authorization: Bearer $GH_TOKEN" "$runs/$GITHUB_RUN_ID/cancel"
348
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
349
+ with: { ref: '${{ inputs.sha }}', persist-credentials: false }
350
+ - env: { HANDED: '${{ inputs.env }}' }
351
+ run: |
352
+ jq -r 'to_entries[] | if (.key | test("^[A-Za-z_][A-Za-z0-9_]*$")) and (.value | type == "string") and (.value | test("[\r\n]") | not) then "\(.key)=\(.value)" else error("env \(.key): a name, and a string of one line") end' <<<"$HANDED" >> "$GITHUB_ENV"
353
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
354
+ # Every project: only pnpm selects by a git diff, as
355
+ # `pnpm install --frozen-lockfile --filter "...[origin/main]"`
356
+ # (fetch origin/main first: the checkout is one commit).
357
+ - run: bun install --frozen-lockfile
358
+ - run: bun vx dte worker
359
+ ```
360
+
361
+ ## On GitLab
362
+
363
+ One pipeline file. Your `vx` job is the orchestrator; once it is on the
364
+ relay, it triggers a pipeline of the same file for each wave of workers
365
+ it asks of a size, with the job's own `CI_JOB_TOKEN`, so no worker starts
366
+ before the run and no token is stored. Each size's job is a matrix of
367
+ `max` slots (`VX_DTE_SLOT`); that pipeline runs only the slots the wave
368
+ names (`VX_DTE_PICK`), at the run's commit, and each takes its name from
369
+ `VX_DTE_IDS`. The run starts workers as its work needs them, up to each
370
+ size's `max`, which `gitlab()` needs (at most 200, a matrix's limit).
371
+ GitLab lets a job token trigger but not cancel, so a worker the run no
372
+ longer needs leaves when it joins or when the run ends. Each trigger
373
+ passes the workers' variables, so set Settings > CI/CD > Variables >
374
+ "Minimum role to use pipeline variables" to Developer: a project made
375
+ since GitLab 17.7 lets no one, and refuses every trigger.
376
+
377
+ ```ts
378
+ // .vx/workspace.ts
379
+ export default defineWorkspace({
380
+ plugins: [
381
+ dte({
382
+ relay: 'https://relay.example.com',
383
+ workers: { small: { max: 4, cpu: 2, memory: '8GB' } },
384
+ adapter: gitlab(),
385
+ }),
386
+ ],
387
+ })
388
+ ```
389
+
390
+ `vx dte init` writes it, from `dte()`, in the package manager your
391
+ lockfile names, asking what main and a merge request run:
392
+
393
+ ```yaml
394
+ # .gitlab-ci.yml
395
+ # Written by vx dte init from dte() in .vx/workspace.ts. The vx job
396
+ # runs no task: once it is on the relay it triggers a pipeline of this
397
+ # file for each wave of workers of a size, which runs only those slots
398
+ # of that size's job.
399
+ # Name the tasks in the vx job, as 'build test --all': with none named,
400
+ # vx fails, listing them.
401
+ workflow:
402
+ rules:
403
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
404
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
405
+ - if: $CI_PIPELINE_SOURCE == "pipeline" && $VX_DTE_SIZE
406
+
407
+ # The image needs Bun; a job adds git if it lacks it. By digest: a tag
408
+ # can move under the same name.
409
+ default:
410
+ image: oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d
411
+
412
+ vx:
413
+ rules:
414
+ - if: $VX_DTE_SIZE
415
+ when: never
416
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
417
+ variables: { VX_TASKS: '--affected' }
418
+ - when: on_success
419
+ variables: { VX_TASKS: '--all' }
420
+ id_tokens: { VX_DTE_TOKEN: { aud: https://relay.example.com } }
421
+ before_script:
422
+ - command -v git || (apt-get update -qq && apt-get install -qq -y --no-install-recommends git)
423
+ - bun install --frozen-lockfile --filter './'
424
+ script:
425
+ - bun vx lock --check
426
+ - bun vx run $VX_TASKS
427
+
428
+ # A worker runs at the run's commit, which its branch may have moved past.
429
+ .vx-worker:
430
+ variables: { GIT_DEPTH: 1 }
431
+ id_tokens: { VX_DTE_TOKEN: { aud: https://relay.example.com } }
432
+ before_script:
433
+ - command -v git || (apt-get update -qq && apt-get install -qq -y --no-install-recommends git)
434
+ - git fetch -q --depth 1 origin $VX_DTE_SHA
435
+ - git checkout -q FETCH_HEAD
436
+ - bun install --frozen-lockfile
437
+ script: [bun vx dte worker]
438
+
439
+ 'vx-worker small':
440
+ extends: .vx-worker
441
+ parallel:
442
+ matrix:
443
+ - VX_DTE_SLOT: ['1', '2', '3', '4']
444
+ rules: [{ if: $VX_DTE_SIZE == "small" && $VX_DTE_SLOT =~ $VX_DTE_PICK }]
445
+ ```
446
+
447
+ `gitlab({ tags })` names each size's runner tags. Each job's ID token
448
+ (`VX_DTE_TOKEN`) admits it to the relay; the worker proves itself to the
449
+ run by the run's secret, made for that run only and passed as a trigger
450
+ variable, since a GitLab ID token's audience is fixed when its job starts
451
+ and cannot bind the handshake as GitHub's does. Set `VX_DTE_SECRET` to
452
+ use your own instead.
453
+
454
+ ## What runs where
455
+
456
+ - A worker reports the cores and memory its machine has when it joins.
457
+ One the run started is held to the size it was started as, so
458
+ `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
462
+ one of its cores is free and the task's memory fits. What a worker has
463
+ in use is what its running tasks used on their last run (history's
464
+ measured CPU and peak RAM), or its live load plus what was sent since
465
+ when that is more. Never less: load lags a task's start, and a test
466
+ shard idle for a second then uses all its cores. A command that ended
467
+ frees its cores while its outputs save, and its worker's load until the
468
+ next frame: that load counted the task. Every
469
+ task sent begins at once; none waits in a worker's queue. A task asking
470
+ a whole worker, or bigger than any, runs alone on an idle one (one that
471
+ filled its worker last time). The first such task waiting holds
472
+ the worker that empties soonest: no task after it is sent there, so it
473
+ is not passed over again and again.
474
+ - A task naming `exec.platforms` runs only on a worker of the platform
475
+ core placed it on: this machine's when a size has it, else the first of
476
+ the task's a size has. A worker reports its platform when it joins: a
477
+ size naming one refuses a worker of another; a size naming none is
478
+ planned as this machine's and takes any worker. When the sizes run
479
+ this machine's platform and another, a task naming none runs only on
480
+ this machine's: a worker of another platform runs only the tasks that
481
+ name it, so they never wait behind the rest, and leaves when they are
482
+ done. The run starts a worker of each
483
+ platform its work needs. A task none of whose platforms any size has
484
+ refuses the run before a worker starts.
485
+ - Tasks waiting for room go out longest tail first: a task's recorded
486
+ duration and its longest chain of dependants, so a long chain that got
487
+ ready late does not queue behind many short tasks ready at once.
488
+ - A task goes to the worker that holds most of its dependencies' outputs,
489
+ so a chain stays on one machine; then to the one with fewest cores
490
+ free that it fits, so workers fill before the next takes work and the
491
+ ones the rest no longer needs fall idle and leave.
492
+ - A sandboxed task runs on a worker inside the worker's own sandbox, with
493
+ the same `exec.sandbox`, built there (`sandboxRequestFor`): its paths,
494
+ walls and credential stores are the worker's, so an orchestrator on
495
+ Linux can run macOS workers. A violation there fails it here, named in
496
+ this checkout's paths. An empty file it wrote over a write grant's
497
+ placeholder is its output; only a placeholder it never touched goes.
498
+ A sandbox the worker cannot host (no `bwrap` in its image) fails the
499
+ task there, as it fails here; the worker stays in the run.
500
+ - A persistent task a task depends on starts on the worker that runs the
501
+ task, once per worker, ready before it, and stops when the worker leaves;
502
+ the orchestrator never starts it, and a worker no dependant reaches
503
+ never does (2026-10-22).
504
+ - A task whose key hashes a runtime probe (`inputs.runtime`, such as
505
+ `bun --version`) runs on a worker, which answers the probe first and
506
+ fails the task when it answers otherwise than this machine did. A path
507
+ under the worker's checkout root reads as the same path under this
508
+ machine's, as the task's environment does.
509
+ - No task runs on the orchestrator, only cache hits restore there
510
+ (2026-10-24): a task with `exec.remote: false`, an interactive
511
+ one and their dependants run on workers too (the executor is
512
+ `exclusive`). A task no worker can take refuses the run before any
513
+ starts, naming it: one whose `exec.platforms` no size offers, or a
514
+ persistent task asked for on its own.
515
+ - An `exec.remote: 'only'` task's files stay on the workers: this machine
516
+ restores its output, never its files. Under `--cache-download=toplevel`
517
+ or `none`, a task's files it defers land here only if a task here needs
518
+ them.
519
+ - A task's output replays here as its worker read it: stdout and stderr
520
+ in the order the task wrote them, bounded as core bounds a run's.
521
+ - A result too long for one relay frame (thousands of sandbox violations,
522
+ or of files a formatter changed) is sent in parts, none of it dropped.
523
+ An order too long for one frame (a task with thousands of deps) sends
524
+ its deps ahead of it the same way.
525
+ - A task runs with the orchestrator's environment under the worker's
526
+ root. `HOME`, `TMPDIR`, `USER` and the like are the worker's own, unless
527
+ the task's `env.define` sets them; `npm_execpath` is the workspace's
528
+ package manager on the worker, as core sets it on a local task.
529
+ - A worker restores, by key, the declared outputs of every task the one
530
+ it runs depends on, each after its own dependencies (an additive task's
531
+ rewrite of an upstream file stands), unless it ran or restored them before. Files a task
532
+ writes without declaring them stay on the worker. An uncached task
533
+ declares none, so no cache carries its files: its dependants go to the
534
+ worker it ran on, unless it is sandboxed with no write grant and so
535
+ leaves no file. One waits there up to 10 s; past that, with that worker
536
+ gone or of another platform, or with its uncached upstreams on two
537
+ 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
542
+ worker removes the files under its outputs that an earlier task left,
543
+ except committed files, nested projects' files, and its dependencies'
544
+ outputs (an additive task builds on its upstream's tree). An upstream's
545
+ file the task leaves as it found it is not saved in its entry; one it
546
+ removes is removed on this machine too, unless a link on its path leads
547
+ elsewhere (that file is kept, with a warning), and the task saves no
548
+ entry, as core saves none.
549
+ - A hit only this machine's cache holds (the remote evicted it, or this
550
+ run does not write the remote) is uploaded to the remotes the workers
551
+ read before a task that depends on it is sent; with none written, its
552
+ dependant fails on the worker, naming the entry. One whose remote copy
553
+ does not read back on a worker (a truncated upload) is sent again from
554
+ this machine over that copy, and the task runs again; once per entry.
555
+ - A failed or uncached task is saved under a key of the run, the task and
556
+ the send (a retry gets its own), so no later lookup ever hits it; so is one core would not save (a
557
+ `--no-cache` or read-only run, a dependant of a failure under
558
+ `--continue=always`, one that left a process running that may still
559
+ write its outputs, one a stopped run cancelled), and one that left a tracked file under its project
560
+ changed on its worker (a formatter's input), which this machine never
561
+ sees: core withholds its own save when the key read that file, and says so.
562
+ - Under `--force`, a success is saved under its key (which keeps an entry
563
+ it already holds, as core does) and under the run's key, which this
564
+ machine and the dependants' workers restore: the bytes this run built,
565
+ never the entry it bypassed.
566
+ - A worker whose tracked files differ from the orchestrator's (another
567
+ tree, or the same commit with a tracked file changed, such as by a
568
+ prepare step) is refused. Both sides read their tracked files as they
569
+ are, so the same change on both is the same tree.
570
+ - A task whose key hashes a file the run's commit lacks (untracked, or
571
+ gitignored and named outright, or inside a submodule, whose files the
572
+ commit holds only as a gitlink) fails, naming the file: no worker has it.
573
+ A dependency's declared output is not one: its worker restores it first.
574
+ - A task's `exec.timeout` counts from its start on its worker: the wait
575
+ for a worker still booting is not its run (the executor declares
576
+ `timeouts`).
577
+ - A worker that stops answering, stops saying it holds a task, or cannot
578
+ 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
581
+ to take it and none starting, it fails at once.
582
+ - A relay that answers nothing for 10 s, every try counted, fails every task not done
583
+ (`the relay is unreachable`); none runs here, which may be far smaller
584
+ than the workers. What finished is saved, so a rerun resumes from it.
585
+ A shorter fault (a `5xx` while the host restarts the relay, a dropped
586
+ connection) is asked through, by the orchestrator and workers alike.
587
+ - The relay stores nothing, so a restart forgets the run. The orchestrator
588
+ joins again as its host and each worker joins again as itself, for two
589
+ 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
592
+ does not run it again, so no work repeats. One not back by then
593
+ ends: a worker's tasks run elsewhere, an orchestrator's run fails.
594
+ Joining has the same 10 s in all, its socket and request together: a
595
+ relay silent that long fails the run with `cannot connect to the relay
596
+ <origin>: no answer in 10 s`.
597
+ - A worker that never joins says why on its row (`failed exited 1:
598
+ …`); its job ending, a success too, is what says so. Once the first
599
+ worker of a size joins, each other of that size not joined within twice
600
+ that first join is given up (`did not join within 60 s`) and its job
601
+ stopped; before one has, none is. One its CI started anyway that joins
602
+ later is taken, as any worker the plan may let go. The first three such failures in a
603
+ run each start another worker in its place, as a checkout, install or
604
+ join can fail on the network; a fourth starts none. While a worker of
605
+ a size is past its warm-up unjoined, or given up so beside one of its
606
+ size online, the run asks no more of that size: its CI queued it, and
607
+ one asked then would queue behind it. When none is online
608
+ or starting and this machine has no share, the remaining tasks fail
609
+ with `no worker could start`.
610
+ - Only `leave` ends a participant at once (2026-10-25): the
611
+ orchestrator's at its run's end is the final call that closes the run,
612
+ and a worker's when it is done. Either whose socket drops without it
613
+ 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
621
+ with no worker yet asks the relay an empty frame instead; a frame the
622
+ relay leaves unanswered a heartbeat closes the socket, which is dialed
623
+ again.
624
+ - Each send is numbered for its receiver and held until the receiver
625
+ 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.
630
+ - A run where no task began or ended for a minute warns what it waits
631
+ on, a line a task: which worker, and whether it runs or saves.
632
+ - A message the relay would not take is a warning naming it, its
633
+ receiver and the relay's answer; one to a participant the relay already
634
+ lost (404) is not, since its loss is reported on its own. Its number
635
+ still reaches the receiver, carrying nothing, so the sends after it are
636
+ not held up. A worker whose
637
+ OIDC token could not be checked is refused by that cause, not as a key
638
+ mismatch, and the run says so.
639
+
640
+ ## What the run shows
641
+
642
+ Each task a worker ran says which one last on its row and after the id
643
+ on its frame, in that worker's own color, by size and the order the run
644
+ started it:
645
+
646
+ ```
647
+ ⏺︎ 308ms success miss c#build · small#1
648
+ ```
649
+
650
+ An idle worker the rest of the run cannot keep busy leaves (`left`): one
651
+ beyond the peak of the cores the remaining tasks keep busy, so a lull
652
+ between two wide parts keeps it. One a remaining task needs for its size
653
+ 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
655
+ 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
657
+ once when its adapter sees its process or job end. Workers start at the first
658
+ task the cache does not answer, so a warm run starts none. Once one has
659
+ joined, its warm-up is what the next of its size takes at the least: work
660
+ that ends sooner starts no worker while others are online or starting, and
661
+ new workers are started for the cores the run keeps busy after that
662
+ warm-up, not before it. A run on CI keeps each size's warm-up in the
663
+ workspace's cache under its commit, and the next commit's run plans by it
664
+ before any worker of that size joins.
665
+
666
+ The `workers` rows, live while the run goes and again in the footer,
667
+ give each worker its status (`starting`, `online`, `left`, `gone`, or `failed`
668
+ with why, such as `exited 1: Cannot find package '@vzn/vx-dte'`; once the
669
+ run is over, one still starting is `not joined`), the tasks it
670
+ has done, failed or had aborted by a stop, and the ones it runs now; while the run goes, those that left
671
+ are one line (`3 workers left 9 done`). A worker the
672
+ 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`
675
+ (`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
677
+ goes; it ends `left` when the run let it go as not needed, `lost` when
678
+ it crashed or went silent, and `stopped` when the run's end stopped it.
679
+ Once the run is over, each worker adds its warm-up (from asking the
680
+ adapter to its join), and under it the cores and memory its size
681
+ declares and its machine reports, green when they agree and red when
682
+ not:
683
+
684
+ ```
685
+ workers small#1 online 3 done · 2 running
686
+ small#2 starting
687
+ local online 1 done
688
+ ```
689
+
690
+ ```
691
+ workers small#1 stopped 5 done · warm-up 0.1s
692
+ declared 2c/8G · actual 4c/16G
693
+ small#2 lost 1 done · warm-up 0.2s
694
+ declared 2c/8G · actual 4c/16G
695
+ local online 1 done
696
+ actual 10c/32G
697
+ ```
698
+
699
+ A run over that started none (every task a hit) says
700
+ `workers none started: no task needed one`.
701
+
702
+ Under each worker, and `local`, the footer then spells out what it did:
703
+ its time online, busy and idle; its CPU peak and average in cores and
704
+ the cores the run booked for the tasks it was running, on average (a
705
+ booked from a task's start to its command's end); its cpu utilization (the core-time its
706
+ 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
707
+ bytes the run sent it and received from it. The `probe` line says when
708
+ core's cache probe answered, how many tasks it left to run (held in no
709
+ cache, or not answered for) and how many of them it found in none. The `asked` line says each
710
+ time the run asked for workers: how many, when, for how many tasks that
711
+ missed, how many of them with a measured need (an unmeasured one counts
712
+ as `unmeasured`), and when its plan ended then. The `ideal` line is the run's ideal end
713
+ (below), so every log reads it beside core's `time` row. The first ask
714
+ waits for the needs another plugin fetches (`ctx.refining()`), such as
715
+ `@vzn/vx-history`'s default-branch timings. The `unmeasured`
716
+ line names the tasks the first ask had no measure of, and a worker that
717
+ ran two tasks or fewer names them on its row. `used avg` is the cores
718
+ its tasks used, `machine avg` the cores its machine was busy on average since
719
+ the worker started, any process's, as its last frame said. Where the
720
+ rest went, the worker says in its own log as it leaves: `cpu over 61.2s:
721
+ machine 2.80 cores · tasks 2.00 cores · this worker 0.15 cores · its other
722
+ children 0.05 cores · most besides: Runner.Worker 0.30 cores`, the last
723
+ from one `ps` as it starts and one as it leaves. The `total` line is the cpu utilization of every
724
+ machine together, and the share of their history the run's tasks used:
725
+
726
+ ```
727
+ small#1 stopped 5 done · warm-up 0.2s
728
+ declared 4c/14G · actual 4c/14G
729
+ online 12.1s · busy 9.8s · idle 2.3s
730
+ cpu peak 3.6 · used avg 2.4 cores · machine avg 2.9 · booked avg 3.8 · cpu utilization 61%
731
+ memory peak 1.2 GB · avg 640.0 MB
732
+ sent 6 messages · 3.1 MB, received 15 messages · 8.4 MB
733
+ probe at 0.4s: 5 tasks left to run, 5 known misses
734
+ asked 1 at 0.5s for 5 tasks (5 measured), plan 12.0s
735
+ ideal 10.8s: each task as long as it took, placed perfectly
736
+ total cpu utilization 58%
737
+ ```
738
+
739
+ `vx run --profile[=<path>]` adds the run in time to the profile it
740
+ writes (open it in https://ui.perfetto.dev or `chrome://tracing`).
741
+ Each worker is a process: its first row holds its warm-up (split, when it
742
+ says when its `vx` started, into its machine's start and vx's boot), its
743
+ idle stretches, how it ended, a `cores` counter of the cores booked
744
+ and used at once, and a `machine` counter of the cores its machine
745
+ reported busy, its tasks' or not (placement never fills a worker past
746
+ it), beside the cores its running tasks' process trees used, read each
747
+ second as core's `task.sample` reads them; each task takes the first lane free over its time
748
+ there, from its send through restoring its dependencies, its command
749
+ and its save, with its exit code, duration, CPU time and booked cores.
750
+ The `orchestrator` process holds the cache probe, each ask for workers, each
751
+ plan that started or let go of workers with what it saw of each worker
752
+ (one that started workers also with each task's planned time, cores and
753
+ platform and each size's warm-up, so it can be played back),
754
+ stalls, the time each task waited for room, and each restore. Times are
755
+ the orchestrator's: when it heard. Without `--profile` nothing is
756
+ written. On CI, upload the file as an artifact; this repo's `ci.yml`
757
+ does and links it from the job summary.
758
+
759
+ After the run, `dte()` replays it perfectly: each task as long as it held
760
+ its worker and with the cores it used meanwhile, on as many
761
+ workers as the sizes allow, each joining after its platform's fastest
762
+ real warm-up. That ideal end (`TaskExecutor.idealMs`) sits beside the
763
+ real time in the run summary and the footer's `ideal` line, and
764
+ `@vzn/vx-ci` prints it.
765
+
766
+ ## Environment
767
+
768
+ What `@vzn/vx-dte` reads from the CI job it runs in:
769
+
770
+ - `GITHUB_SHA`: the commit every worker checks out.
771
+ - `GITHUB_HEAD_REF` or `GITHUB_REF_NAME`, then `GITHUB_BASE_REF`: the
772
+ ref `github()` dispatches the worker workflow on, the base when the
773
+ head has no workflow file.
774
+ - `GITHUB_RUN_ID` and `GITHUB_RUN_ATTEMPT`: the run's attempt, so a
775
+ worker that starts after it ended cancels itself.
776
+ - `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN`:
777
+ where the job asks GitHub for its OIDC token; `GITHUB_REPOSITORY_ID`:
778
+ the repository a token must name.
779
+ - `GITLAB_CI` and `CI_JOB_TOKEN`: `gitlab()` runs on GitLab CI.
780
+ - `CI_MERGE_REQUEST_SOURCE_BRANCH_NAME` or `CI_COMMIT_REF_NAME`, then
781
+ `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`: the ref worker pipelines run on.
782
+ - `PATH`: a worker keeps the task's own bin directories, then its own
783
+ machine's `PATH`.
784
+ - `GIT_INDEX_FILE`: `local()` clears it before `git worktree add`, so a
785
+ run started from a git hook checks out the run's commit, not the hook's
786
+ index.
787
+
788
+ ## API
789
+
790
+ - `dte(options?: DteOptions)` is the plugin. `DteOptions` is
791
+ `{ workers?, adapter?, relay?, local?, token?, scheduler?, unmeasured?, headers? }`;
792
+ a key it does not take, in a size or `unmeasured` too, is refused.
793
+ `headers` go on every dial to the relay, the orchestrator's and each
794
+ worker's (redials too), for a proxy in front of it, such as a
795
+ Cloudflare Access service token (`{ 'CF-Access-Client-Id':
796
+ process.env.CF_ACCESS_CLIENT_ID, … }`); an unset value is left out, and
797
+ a name the dial sets itself (`authorization`, `content-type`,
798
+ `relay-proof`) is refused. The rest:
799
+ - `workers` names each `WorkerSize`, `{ cpu, memory, max?, platform? }`: its cores
800
+ and memory (bytes, or `'8GB'`, `'512MiB'`), which the run counts on
801
+ when it starts one, until the worker reports what its machine has,
802
+ how many of it one run may start (absent, no cap), and its machines'
803
+ platform (`linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`;
804
+ absent, planned as this machine's and not checked on join). With no adapter, the sizes' platforms are
805
+ the ones joining workers may have. Only work counts:
806
+ a task that missed the cache, or waits on a cached one that did, and
807
+ every uncached task, which runs every time; groups and hits need no worker. At each miss, finish and join the run plays
808
+ that work out on its workers and on N more (`src/planner.ts`), each new
809
+ one joining after its size's warm-up: this run's first of that size,
810
+ else an earlier run's, else any size's this run, else 20 s,
811
+ and takes the fewest N that end within 5% (5 s at least) of the best.
812
+ Sizes of different platforms come in turns, so of those N it drops
813
+ each the plan ends as soon without, its platform's work no more than
814
+ 5 s later.
815
+ That margin is spent once: later plans keep its deadline until new
816
+ work is known. A task waiting on one that runs waits until it ends.
817
+ What is left, running tasks too, takes as many times its history as
818
+ the run's finished tasks took theirs (once they had 20 s of it; never
819
+ less): a task past its forecast would otherwise be planned to end now,
820
+ and a plan that thinks the run nearly done cancels the worker it asked.
821
+ Before any worker can say what misses, the run counts as work each
822
+ cached task whose inputs changed since HEAD's parent (`git diff
823
+ HEAD^`), and every task after one (`src/forecast.ts`): a lockfile or
824
+ `.vx/` change counts every cached task. A hit it guessed wrong costs a
825
+ worker the plan cancels; with no parent, the run waits for its misses.
826
+ Each task core's up-front cache probe found in no store
827
+ (`TaskExecutor.demand`'s `missed`) counts as work too, the moment the
828
+ probe answers.
829
+ A measured task books its cores times the share of their history the
830
+ run's finished tasks used (once they used a minute of core-time; never
831
+ under two thirds), so a worker's bookings may reach 1.5 times its
832
+ cores when tasks use less than history says; plans book the same.
833
+ What a task used counts divided by its share of the CPU: a Linux
834
+ worker reads its run queue (`procs_running` in /proc/stat) every 200 ms,
835
+ a macOS one its load average while all its cores are busy (a GitHub Mac reads a load of 49 with a core idle), and a task whose threads got a third of a core each used a third of
836
+ its need, not its need. A task wanted at most what it used and the
837
+ cores left free beside it, so its share is never read below that. The run history keeps that share
838
+ (`ExecuteResult.machine.cpuShare`) and learns the need, at most the
839
+ machine's cores: learning the share packed the next run tighter, nine
840
+ 1.1-core test shards on one 4-core worker. The need is the mean over
841
+ runs, and a task fills its worker only when it does on average: one
842
+ crowded run's 2.84 cores for a build using 1.3 to 1.9 booked it a whole
843
+ Mac.
844
+ A worker the plan needs later starts when its warm-up nears. An idle
845
+ worker leaves when the plan gives it no task (its warm-up is spent), or,
846
+ when its next task is two warm-ups away, with one started in its place
847
+ a warm-up after it left: none is let go and started again. A worker still
848
+ starting that the plan gives no task (asked, a worker stays while the
849
+ plan has work for it, though the run would end no later without) is
850
+ cancelled: the adapter
851
+ stops it before its setup if it can (`github()` cancels its workflow
852
+ run once no worker in it is needed and none of its jobs has a runner),
853
+ and one that joins anyway is told to exit. One the adapter leaves
854
+ running (its job had a runner, its run holds a worker still needed, or
855
+ the adapter stops none) is counted again, and the run takes it when it
856
+ joins: its setup is paid, and a cancel landing late could stop it
857
+ mid-task. The same signal changes nothing. The
858
+ run ends: a task the history has not measured books one free core on
859
+ any worker (`unmeasured` sizes the run); one that used all of its worker's cores or memory (90%) last time runs
860
+ alone on the smallest bigger size, which the run starts for it. Each
861
+ run's history keeps where every task ran and that machine's size, so
862
+ sizing improves run by run.
863
+ - `adapter` is the `DteAdapter` that starts workers, of the sizes in
864
+ `workers`: at least one with `max` above 0, or `dte()` is refused.
865
+ Absent, the run starts none and uses the workers that join `relay`.
866
+ - `relay` is the relay's URL, http(s); absent, the adapter's own.
867
+ - `token` answers the relay's bearer token (default `VX_DTE_TOKEN`). It
868
+ is asked again a minute before the token's `exp`, when it has one.
869
+ - `unmeasured`, `{ cpu, memory }` (default 2 cores, `'4GB'`), is what a
870
+ task with no measure counts for when the run decides how many workers
871
+ to start: a new task, or one over before the Linux sandbox's first
872
+ look (250 ms), which leaves no CPU. It books one free core on any
873
+ worker, so it waits for no worker of its own.
874
+ - `scheduler` (a partial `Strategy`) picks each layer of how the run schedules; a layer left
875
+ out is the default (first named):
876
+ - `order`, which waiting task goes first: `critical-path` (the
877
+ longest chain after it), `graphene` (the long and the wide first),
878
+ `fifo`;
879
+ - `place`, which worker takes it: `best-fit` (fewest cores left),
880
+ `tetris` (needs aligned with what is free), `worst-fit`,
881
+ `first-fit`;
882
+ - `book`, what a task holds: `measured` (its history), `margin`
883
+ (a fifth more), `whole` (a worker each);
884
+ - `scale`, how many workers: `plan` (above), `backlog` (cores, of each platform, for
885
+ what is ready or running, Spark's dynamic allocation).
886
+ `tests/sim.test.ts` plays main's runs with each.
887
+ - `vx dte worker` is a worker. It reads `VX_DTE_RUN`, `VX_DTE_RELAY` (unset or empty,
888
+ `relay`), `VX_DTE_SECRET` (the run's) and, when the run started it,
889
+ `VX_DTE_WORKER` (its name). It is
890
+ described only by the cores and memory its machine reports. It exits 0
891
+ when the run ended before it joined. On SIGINT or SIGTERM it stops its
892
+ tasks, leaves the run, which runs them again on another worker, and
893
+ exits 130 or 143; a second signal ends it at once.
894
+ - `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).
895
+ `WorkerSpawn` is `{ run, relay, size, max, cpu, memory, index, id, workspaceRoot, commit, cacheDir?, env }`,
896
+ with `memory` in bytes and `max` its size's (`Infinity` uncapped),
897
+ where `id` is the worker's name (`small#2`, numbered within its size), `commit` the
898
+ tracked files as the run saw them at its start (uncommitted changes included), whose
899
+ tree a worker must check out, `cacheDir` the run's cache dir (absolute) when the
900
+ workspace names one, for workers on this machine to save where the run reads, and
901
+ `env` holds the three variables the worker reads.
902
+ - `docker(options: DockerOptions)` starts workers as containers and a relay
903
+ of its own on loopback. `DockerOptions` is `{ image, prepare? }`.
904
+ - `github(options?: GithubOptions)` dispatches workers as matrix jobs of
905
+ one workflow, one dispatch per size and demand (at most 256 workers,
906
+ a matrix's limit). `GithubOptions` is `{ workflow?, runsOn?, env? }`:
907
+ `workflow` defaults to `vx-worker.yml`, a file name in
908
+ `.github/workflows` other than `ci.yml`, the run's own; `runsOn` is the runner label
909
+ of each size, which `vx dte init` writes; `env` is variables every
910
+ worker gets (one-line strings, no secret-named one: the worker job's
911
+ log shows them), sent as the
912
+ dispatch's `env` input only when set, which `vx dte init`'s workflow
913
+ exports. Its workers are `keyless`, and only jobs of the workflow runs
914
+ it dispatched join as them. It
915
+ needs `GITHUB_TOKEN` with `actions: write`. See [On GitHub](#on-github).
916
+ - `gitlab(options?: GitlabOptions)` triggers a pipeline of the project's
917
+ `.gitlab-ci.yml` per wave and size, with `CI_JOB_TOKEN`. `GitlabOptions` is
918
+ `{ tags? }`: runner tags by size, which `vx dte init` writes. See
919
+ [On GitLab](#on-gitlab).
920
+ - `local(options?: LocalOptions)` starts workers on this machine and a relay
921
+ of its own on loopback. `LocalOptions` is `{ prepare? }`: a shell line
922
+ each worktree runs before its worker starts.
923
+
924
+ `@vzn/vx-dte-server` is a dependency for `local()`'s and `docker()`'s relay, loaded only
925
+ when a DTE run starts.