Research into self-hosted runners (GitHub Actions, GitLab, Buildkite,
80
Jenkins, Forgejo; 2023–2026) found the same complaints everywhere. Most of
81
them are fleet problems, not pipeline problems.
82
83
| What goes wrong elsewhere | Jetstream |
84
|---|---|
85
| Persistent runners leak state; fork PRs and compromised actions reach other jobs' secrets | A fresh microVM per job (or the syscall tier); nothing survives a job |
86
| A job waits for hours, then fails, with no reason given | Every waiting job says why, in terms of facts (§7) |
87
| Disk fills mid-job; an OOM kill shows up as a "lost runner" | Disk and memory checked on every claim against what the job needed last time; OOM reported as OOM |
88
| Images copied to each machine by hand; slow pulls | Images served by hash, fetched by the runner on demand, chunked so a rebuild moves only what changed (§8) |
89
| Exact-match labels that sprawl | Facts measured by the runner; labels only steer (§7) |
90
| Lost runners and spot reclaims leave jobs hanging | Requeue on a lost runner or a reclaim notice (§11) |
91
| Forced runner upgrades with a cut-off date | Runners update themselves between jobs; the contract is versioned |
92
| Ephemeral runners start cold, 30–60 s and no caches | Firecracker boots in about a second; images and caches stay warm by hash |
93
| Multi-arch builds through QEMU, 5–30× slower; no RISC-V runner at all | Native runners per architecture, RISC-V included (narvi) |
94
| No queue, utilisation or cost figures | Queue time, utilisation and cost by job, pool and region, built in (§11) |
95
96
##3. Responsibilities
97
98
|| Jetstream | The forge (OxygenHub) |
99
|---|---|---|
100
| Pipeline from the repository || ✔ |
101
| Runs, jobs, `needs`, the run page || ✔ |
102
| Queue, placement, why a job waits | ✔ | shows it |
103
| Runners, their facts and tokens | ✔ | shows them |
104
| Pools: hosts, Kubernetes, cloud | ✔ ||
105
| Images | ✔ (by hash) ||
106
| Secrets and variables | carries them to the job | ✔ (stores them) |
-**Oxygen** (`oxy://HUB/OWNER/NAME@STATE`): flow-casfs mounts the state
154
read-only and reads objects as the job touches them (spec §5.1). Nothing
155
is checked out; a job that reads 50 files of a 100,000-file repository
156
fetches 50 files. Jobs whose scope a change does not touch keep their
157
previous result (spec §6), and receipts name the exact state.
158
-**Git** (`git+https://…@COMMIT`): the runner clones or fetches the
159
commit into the job's disk before it starts, as every CI system does.
160
161
Both run through the same fleet, so the difference is measured, not
162
claimed: the job page shows how long the source took.
163
164
```mermaid
165
flowchart TB
166
subgraph O["Oxygen source"]
167
O1["job starts"] --> O2["mount state (ms)"] --> O3["read files as used"]
168
end
169
subgraph G["Git source"]
170
G1["clone / fetch commit"] --> G2["check out every file"] --> G3["job starts"]
171
end
172
```
173
174
##6. Facts: runners know what they are
175
176
A runner measures itself, and sends what it measured **with every claim**.
177
Idle runners claim every few seconds, so Jetstream always holds every idle
178
runner's facts from moments ago; there is no separate audit channel to go
179
stale.
180
181
| Fact | Measured by | When |
182
|---|---|---|
183
| Architecture, CPU model, cores |`uname`, `/proc/cpuinfo`| at start |
184
| Tiers: microvm (KVM opened, not assumed), syscall (Landlock ABI), wasm | probing | at start, then hourly |
185
| Memory free, disk free where jobs run |`/proc/meminfo`, `statvfs`| every claim |
186
| Load, jobs running |`/proc/loadavg`, its own state | every claim |
187
| Images held | its image store | every claim |
188
| Speed score | a fixed few-second benchmark (hashing, compression, a small compile), normalised so earth-1 ≈ 100 | at start, then about every 15 minutes while idle |
189
| Where it runs | the pool: host, cluster and node, or cloud, region, instance type, spot | at start |
190
191
The score is checked against reality: Jetstream compares each runner's
192
claimed speed with how fast its jobs actually ran, and ranks a runner
193
whose real timings drift (thermal throttling, noisy neighbours) by what
194
it achieved.
195
196
##7. Placement: requirements, ranking, and why
197
198
**A job's requirements are mostly derived**, never typed by the user:
199
200
-architecture from its image;
201
- tier from its sandbox;
202
- disk and memory **learned from its own past runs** (peak plus
203
headroom); a new job goes to the roomiest runner the first time;
204
- an explicit floor when the pipeline gives one.
205
206
**Placement** filters, then ranks:
207
208
1.**Filter on hard facts:** architecture, tier, enough free disk and
209
memory now, steering that requires a pool or label.
210
2.**Rank on soft ones:** already holds the image (warm), idle, speed
211
score, the shortest estimated finish.
212
3.**A short hold for the best fit:** when the runner asking is a poor
213
fit and a better one claimed seconds ago, the job is held a few seconds
214
for it, then goes to whoever is next.
215
4.**The runner has the last word:** it checks again on accept and
216
declines with a reason if its disk filled meanwhile.
217
218
**Every decision explains itself**, on the job and through the API:
219
220
> ran on **earth-1**: holds the image, idle, score 100, estimated 4 min
221
222
>**waiting**: needs ~12 GiB disk (its last run used 10.4); plex-1 has 9;
223
> earth-1 is busy until about 11:40
224
225
**Labels only steer.**`runs-on "plex"` pins, `prefer "fast"` biases. A
226
user who never writes one never needs to.
227
228
##8. Images
229
230
-**Content-addressed.** An image is an ext4 root file system named by
231
its BLAKE3 hash (`cas://blob/…`), as today. A sandbox may name one per
232
architecture.
233
-**Served by Jetstream.** A runner that lacks an image fetches it by hash
234
and verifies it; nobody copies images between machines. Images are
235
chunked, so a rebuilt image moves only its changed chunks.
236
-**Built by Flow.** An image is a job's output: a project's
237
`.flow/image/` becomes a job, its image is kept and served, and the
238
sandbox's pin follows it (a proposal: "image changed: af3c… → 91be…").
239
-**Architecture recorded at build**, so an x86 image never reaches a
240
RISC-V runner.
241
242
##9. Pools
243
244
A **pool** is one place runners come from. All pools report the same
245
facts and follow the same placement.
246
247
```mermaid
248
flowchart TB
249
J["Jetstream"]
250
subgraph Hosts["Host pool"]
251
E["earth-1<br/>x86_64 · microvm"]
252
PL["plex-1<br/>x86_64 · microvm"]
253
N["narvi-1<br/>riscv64 · syscall"]
254
end
255
subgraph K8s["Kubernetes pool"]
256
CTL["jet controller"]
257
KP1["KVM node pool<br/>Firecracker in pod"]
258
KP2["Kata / gVisor<br/>RuntimeClass"]
259
KP3["ordinary pods<br/>syscall tier"]
260
CTL --> KP1
261
CTL --> KP2
262
CTL --> KP3
263
end
264
subgraph Cloud["Cloud pool (europe-west2)"]
265
PRV["jet provider"]
266
VM1["warm VM, nested KVM<br/>several microVMs"]
267
VM2["VM per job<br/>syscall tier"]
268
PRV --> VM1
269
PRV --> VM2
270
end
271
J --> Hosts
272
J --> CTL
273
J --> PRV
274
```
275
276
**Hosts.** Machines you own run `jet runner` (today's `flow runner`).
277
278
**Kubernetes.**`jet controller` runs in the cluster, claims for it, and
279
creates **one pod per job**, the pattern every major CI system uses on
280
Kubernetes. The tier follows what each node pool offers:
281
282
| Node pool offers | Tier |
283
|---|---|
284
|`/dev/kvm` through a device plugin (a tainted pool) | microvm: Firecracker inside the pod |
285
| A Kata or gVisor RuntimeClass (AKS Pod Sandboxing, GKE Sandbox) | syscall, inside the platform's sandbox |
286
| Nothing special (GKE Autopilot included) | syscall, in an unprivileged pod |
287
288
Dedicated KVM hosts beside a cluster stay first class: that is how
289
microVM CI is run in practice.
290
291
**Cloud providers.**`jet provider` creates and removes VMs in a cloud
292
account, keyless through OIDC (OxygenHub is already an issuer, OxygenHub spec
293
§13.5):
294
295
-**Packing first:** a warm 8–16 vCPU VM with nested KVM runs several
296
microVM jobs, saving the 30–60 s boot, the cold disk and per-VM minimum
297
charges. Nested KVM: GCP Intel types and N4D, AWS C7i/C8i and newer
298
(since 2026), Azure Dsv5.
299
-**One VM per job** with the syscall tier inside is the fallback where
300
nested KVM is not offered.
301
-**Regions are pinned** per pool and never fall back outside the set:
302
for the UK, `europe-west2`, `uksouth`, `eu-west-2`. Every job records
303
where it ran.
304
-**Spot**, with on-demand fallback; a reclaim notice requeues the job.
305
-**Scale-in** only of idle VMs near the end of their billing increment.
306
- Build order: GCP, then AWS, then Azure.
307
308
The provider interface: `create(n, spec)`, `delete(ids)`, `list()`,
309
`health(id)`, `capabilities()`, `quota()`.
310
311
##10. Demand and autoscaling
312
313
Jetstream answers **demand by pull**, so nothing needs a way into your
314
network:
315
316
-`GET /demand?pool=…` (long-poll): waiting jobs grouped by architecture,
317
tier and size, and the age of the oldest. Kubernetes pools scale on it
318
through a KEDA scaler; cloud providers through their own loop.
319
-**One-shot runner tokens** for ephemeral runners and pods: issued for
320
one start, never long-lived.
321
322
##11. Reliability and running it
323
324
-**Lost runner:** its jobs are requeued after a short grace; a spot
325
reclaim notice requeues at once.
326
-**OOM** is reported as OOM, with the job's peak, and raises its learned
327
memory need.
328
-**Updates:** a runner told of a newer version updates itself between
329
jobs. No cut-off date; the contract is versioned and old runners keep
330
working until they update.
331
-**Figures:** queue time (the single most useful CI metric), utilisation
332
per runner and pool, and cost per job, pool and region where a provider
333
knows its prices.
334
335
##12. Embedded or on its own
336
337
```mermaid
338
flowchart LR
339
subgraph A["Embedded (the default)"]
340
OH1["OxygenHub process<br/>+ jet inside"]
341
R1["runners"] --> OH1
342
end
343
subgraph B["On its own"]
344
OH2["OxygenHub"] --> JS2["jet server"]
345
OH3["another OxygenHub"] --> JS2
346
R2["runners"] --> JS2
347
end
348
```
349
350
-**Embedded:** the same crate in OxygenHub's process, with no
351
configuration. This is what OxygenHub does unless told otherwise.
352
-**On its own:**`[flow] jetstream = "https://jet.example"` in the hub's
353
configuration points it at a standalone `jet server`; several hubs may
354
share one fleet.
355
-**Other forges, later:** the forge contract (§4) and Git sources (§5)
356
are what an adapter needs, such as one that runs GitLab or GitHub jobs
357
on Jetstream runners. That would be a contained piece of work, not a
358
rewrite; it is not planned yet.
359
360
##13. Running a pipeline on your own machine
361
362
```sh
363
flow run # every job of .flow/pipeline.kdl, on this directory's files
364
flow run test # one job, and what it needs
365
flow run --var X=1 --secret TOKEN=… # what the hub would supply
366
```
367
368
`flow run` exists today: it runs the pipeline's jobs in the same sandboxes
369
CI uses, before anything is pushed. With Jetstream it becomes an
370
in-process Jetstream with one local runner, the same placement and the
371
same code path as CI, so passing locally means passing in CI. With images
372
served by Jetstream (§8), the first local run fetches the pinned image by
373
hash instead of needing it imported by hand.
374
375
##14. Security
376
377
-**Isolation per job:** a microVM where KVM exists, the syscall tier
378
(Landlock and seccomp) elsewhere, a platform sandbox where a cluster
379
offers one. No job ever shares a Docker daemon or a host socket.
380
-**Secrets** are mounted as files in the job and masked in logs (spec
381
§9), never written to a runner's disk outside the job.
382
-**Cloud VMs** have no cloud identity of their own, the metadata service
383
is blocked from inside job microVMs, and job subnets accept nothing
384
inbound.
385
-**Runner tokens** are per runner, and one-shot for ephemeral ones.
386
387
##15. Order of work
388
389
1.**The boundary, inside the hub:** queue, placement and runners behind
390
the Jetstream contract in a `jet` crate, embedded; no behaviour change,
391
CI proves it.
392
2.**Images served by Jetstream**, fetched by hash; images built by Flow
393
jobs.
394
3.**Facts on every claim, learned requirements, placement with its
395
explanation**, on the job page and the queue.
396
4.**narvi-1**, the first runner brought up this way (riscv64, syscall).
397
5.**`jet server`**, OxygenHub pointing at it.
398
6.**The Kubernetes controller**, with a KEDA scaler on `/demand`.
399
7.**Cloud providers:** GCP, then AWS, then Azure.
400
401
##16. Open questions
402
403
-Whether artifacts and report files travel through Jetstream or go
404
straight from the runner to the forge (today: straight to the hub).
405
- Multi-tenancy in a shared `jet server`: pools per organisation, and
406
who may submit to which.
407
- How long the best-fit hold (§7) should be, and whether it should learn.
408
- Receipts: the runner signs; whether Jetstream countersigns where it