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