OXYGENHUB

Design: runners update themselves (refs #84) c90dd8d5fc36

Jetstream §8.2: runner builds from releases through the swarm, checked as
a release with their signed build receipt; off unless chosen, nearest
setting wins (site, organisation, test group, runner) and a runner can
refuse; test groups by percentage or chosen runners, in waves with soak
and health; drain, swap, prove, roll back. For Andy's review.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 661a76bd6613
When
· 2026-10-25 23:34 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
c90dd8d5fc36bc3cc6d52a0c48b4b39becc2415c97602577838bfb850ebc826f
Depends on
2814f4213e7c b14aeaae683a
Depended on by
3e3cc9475ab3 8aa45fa6ece5 85275e1ef3b7
In bookmarks
main
In tags
v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
1 atom, 0 tree operations, 1 file

Browse the tree at this change

modified docs/jetstream.md +76 −0
…
294 unchanged lines
295 295
upload_mib_s = 50
296 296
```
297 297
298 +
### 8.2 Runners update themselves
299 +
300 +
Proposed 2026-10-25 from Andy's experience: keeping a large fleet's
301 +
runners up to date by hand is one of the biggest costs of running CI.
302 +
Runners fetch new runner builds the way they fetch images, from each other
303 +
through the swarm, check them as strictly as a release, and swap
304 +
themselves in without losing work, under a policy the organisation sets,
305 +
testing on a few runners before the rest.
306 +
307 +
**A runner build** is a `flow` binary from an OxygenFlow release, one per
308 +
target (linux-x86_64, linux-arm64, linux-riscv64; Windows and macOS when
309 +
those runners exist), built by Flow from the tagged state with a signed
310 +
receipt, as oxy's and OxygenHub's releases are. The hub serves it as a
311 +
blob with a chunk manifest, so the swarm (§8.1) carries it unchanged: one
312 +
runner of a site fetches each chunk from the hub and the rest from it.
313 +
314 +
**Off unless chosen, nearest wins.** The policy is set for the site, an
315 +
organisation, a test group and a runner; the nearest setting wins:
316 +
317 +
- `updates`: `off` (the default) or `on`;
318 +
- `channel`: `stable`, `canary`, or a pinned version (`0.3.4`);
319 +
- `window`: hours in which a runner may update (outside them it waits);
320 +
- `dry_run`: fetch and check, say what would happen, do not swap.
321 +
322 +
A runner's own `flow.toml` has the last word: `[runner.update] allow =
323 +
false` refuses every update whatever the hub says, so a site can hold a
324 +
machine back while it tests.
325 +
326 +
**Test groups and waves.** A test group is a named set of runners: chosen
327 +
by name, by label or by pool. A rollout is a release and its waves, in
328 +
order; each wave is a group and its share of it, **a percentage or chosen
329 +
runners**. A percentage is taken by a stable hash of each runner's id, so
330 +
"10% of build-farm" is the same machines every rollout, the ones the
331 +
organisation watches. A wave starts when the one before has been healthy
332 +
for its soak time (say 24 hours) or when an admin promotes it. For
333 +
example: canary (two chosen runners), then build-farm at 10%, then
334 +
build-farm at 100%, then everyone.
335 +
336 +
**Healthy** means the updated runners came back on the new version within
337 +
ten minutes, claim jobs, and their jobs' infrastructure errors (`lost`,
338 +
`error`, declines) over the soak are no worse than the same runners' on
339 +
the version before, over enough jobs to tell. A wave that is not healthy
340 +
halts the rollout, and its runners roll back. Job failures from the
341 +
pipelines themselves do not count.
342 +
343 +
**On the runner,** in order:
344 +
345 +
1. The hub's answer to a claim or heartbeat names an update when the
346 +
   policy says so: the version, its blob and manifest, its receipt.
347 +
2. Fetch the blob through the swarm (hub as seed); every chunk checked
348 +
   against the manifest from the hub.
349 +
3. Check it as a release: its BLAKE3 matches the release asset the hub
350 +
   attests; the receipt is signed by an `owned` build runner (trust
351 +
   classes, #79) and names the tagged state; the target is this machine's.
352 +
   A peer can only serve bytes; it cannot change what is installed.
353 +
4. Stage it (`bin/flow.new`) and run its self-check (`flow.new tiers`,
354 +
   `--version`).
355 +
5. Drain with notice (`leave_within`, §11.5.2 of the spec): finish what
356 +
   fits, hand back the rest with its checkpoints.
357 +
6. Swap: `flow` → `flow.prev`, `flow.new` → `flow`, and restart.
358 +
7. Prove it: the first claim reports the new version. A runner that will
359 +
   not start, or that the hub does not hear from within ten minutes, goes
360 +
   back to `flow.prev` (a start counter in the runner's home: three
361 +
   failed starts after an update restore the previous build).
362 +
8. The runner's page shows its version, channel, group, last update and
363 +
   why; the audit log records every update and rollback.
364 +
365 +
**Phones** take runner updates with the app's releases (Android updates an
366 +
app only through its store or an install), so they follow the app.
367 +
368 +
**Order of work:** (1) the runner side, with a local source (`flow runner
369 +
update --from FILE`): check, stage, drain, swap, prove, roll back; (2) the
370 +
policy on the hub (opt in and out, channels, windows, dry run) and the
371 +
update in the claim answer; (3) test groups, waves and health; (4) the
372 +
swarm as transport; (5) Admin › Runners › Updates and `oh runner update`.
373 +
298 374
## 9. Pools
299 375
300 376
A **pool** is one place runners come from. All pools report the same
…
165 unchanged lines