| 39e737fb Andy McCall ↶ |
1 |
# Handover: jobs that outlive their runner |
| 2 |
|
| 3 |
Proposed 2026-10-25, from a design conversation with Andy. Issues: |
| 4 |
oxygenflow#75 (WASM tier), #76 (leases and drain), #77 (checkpoints), |
| 5 |
#78 (hedging), #79 (trust classes), #80 (browser runners); the phone |
| 6 |
runner is oxygenhub-android#8. Builds on Jetstream (docs/jetstream.md), |
| 7 |
whose pools (hosts, Kubernetes, cloud) and requeue of a lost runner's job |
| 8 |
this extends. |
| 9 |
|
| 10 |
## 1. Why |
| 11 |
|
| 12 |
Three situations, one problem: a job is tied to the runner that started |
| 13 |
it. |
| 14 |
|
| 15 |
- **The runner goes away.** A phone leaves the office Wi-Fi or comes off |
| 16 |
charge; a laptop sleeps; a spot VM is reclaimed with 30 seconds' notice; |
| 17 |
a Kubernetes node is drained. Today the job starts again from nothing |
| 18 |
elsewhere, or fails. |
| 19 |
- **A better runner frees up.** A job went to the small runner B because |
| 20 |
the big runner A was busy. A finishes its own job when B is 10% through, |
| 21 |
then sits idle while B grinds through the other 90%. |
| 22 |
- **The same work runs twice.** A requeued job redoes everything; two |
| 23 |
pipelines that build the same thing both build it. |
| 24 |
|
| 25 |
Solving the first makes unreliable runners usable (phones, browsers, |
| 26 |
spare machines, spot instances). Solving the second keeps a mixed fleet |
| 27 |
busy. Together they are the case a company makes for running CI on the |
| 28 |
hardware it already has: more use from it, and less waiting for |
| 29 |
developers and agents. |
| 30 |
|
| 31 |
## 2. What Flow already has |
| 32 |
|
| 33 |
Most of the parts exist: |
| 34 |
|
| 35 |
- **Jobs are the packets.** A job runs one `exec`; jobs form a graph and |
| 36 |
pass artifacts (§5 of the spec, oxygenflow#58). A run is already spread |
| 37 |
over runners: oxygenhub's run #439 built on earth-1, ran clippy on |
| 38 |
plex-1, dist on plex-1 and riscv64 on narvi-1. |
| 39 |
- **Receipts already chain.** Each job's receipt (spec §6.1) is signed by |
| 40 |
its runner and names its inputs, which are other jobs' outputs, whose |
| 41 |
receipts name *their* runners. A run's provenance is already a graph of |
| 42 |
signed receipts from several runners. |
| 43 |
- **A lost runner costs only its job.** Jetstream requeues it after a |
| 44 |
grace, and a spot reclaim notice requeues it at once (jetstream.md §11). |
| 45 |
- **Placement can wait for a better runner** ("held for a better |
| 46 |
runner"), and the hub learns each job's duration per speed class |
| 47 |
(spec §11). |
| 48 |
|
| 49 |
What is missing: a job that is *partly done* is lost when it moves; there |
| 50 |
is no graceful way to leave; a better runner cannot take over; nothing |
| 51 |
says how far to trust a runner; and phones and browsers cannot run jobs |
| 52 |
at all (no WASM tier). |
| 53 |
|
| 54 |
## 3. Decision: move progress, not processes |
| 55 |
|
| 56 |
**No live migration of running VMs** (oxygenflow#33 suggested Firecracker |
| 57 |
snapshots). A snapshot fixes the VM's shape: a job started with 2 vCPUs |
| 58 |
on a small runner is restored with 2 vCPUs on the big one, since |
| 59 |
Firecracker cannot add vCPUs to a running VM, so it gets no faster. It |
| 60 |
moves gigabytes of memory, works only between hosts of the same |
| 61 |
architecture and CPU features, and cannot help when the runner simply |
| 62 |
disappears, because the snapshot is on the runner that is gone. |
| 63 |
|
| 64 |
Instead a job's **progress** moves: the outputs of finished jobs (as |
| 65 |
today) and, within a job, the directories it declares as checkpoints |
| 66 |
(§7). Everything is content-addressed, so the new runner fetches only what |
| 67 |
it lacks, and every piece comes with a receipt saying who made it. |
| 68 |
|
| 69 |
## 4. Runners |
| 70 |
|
| 71 |
|
| 72 |
|
| 73 |
| Hosts (earth-1, plex-1, mars-1, narvi-1) | microvm, syscall, wasm | owned | restart or update (drain) | |
| 74 |
| Kubernetes pods | microvm (KVM nodes), syscall, wasm | owned or attested | node drain, eviction, scale-down (SIGTERM) | |
| 75 |
| Cloud VMs, spot or on demand | microvm, syscall, wasm | attested | reclaim notice, scale-down | |
| 76 |
| Phone, the easy way (oxygenhub-android#8) | wasm | device | off charge, off the allowed network, too hot, owner stops it | |
| 77 |
| Phone, full Linux (oxygenhub-android#9, later) | syscall in Android's Linux VM | device | as above | |
| 78 |
| Browser tab (#80) | wasm | anonymous, or device when a member signs in | tab closed or hidden | |
| 79 |
|
| 80 |
Cloud and Kubernetes are where handover pays most: spot capacity costs |
| 81 |
60 to 90% less, and handover is what makes it safe to run CI on it. |
| 82 |
Phones and browsers are where it shows best in a demonstration. |
| 83 |
|
| 84 |
**Architecture still matters.** Native outputs and checkpoints only move |
| 85 |
between runners of the same image and target (x86_64 to x86_64, arm64 to |
| 86 |
arm64). WASM work moves anywhere. |
| 87 |
|
| 88 |
## 5. The WASM tier (oxygenflow#75) |
| 89 |
|
| 28cc603f Andy McCall ↶ |
90 |
Spec §4.1 names it; built 2026-10-25 (wasmtime 49, WASI preview 1). |
| 39e737fb Andy McCall ↶ |
91 |
|
| 92 |
- wasmtime, WASI preview 1 first (preview 2 components later). |
| 93 |
- A virtual file system holding the job's scope and inputs; outputs |
| 94 |
collected from it as for the other tiers. |
| 95 |
- No network. Memory from the sandbox's limit; CPU time bounded by fuel |
| 96 |
and the job's time limit. |
| 97 |
- The receipt's environment names the runtime, its version and the |
| 98 |
module's hash. |
| 99 |
- Every Linux runner offers it (`tiers` gains `wasm`); phones and |
| 100 |
browsers offer only it. |
| 101 |
|
| 102 |
What runs there is what compiles to WASM: linters, formatters, policy |
| 103 |
checks, generated docs, and **test suites built for `wasm32-wasip1`**. |
| 104 |
That last one makes the first demonstration (§11): a native job builds |
| 105 |
the test modules, a phone runs them. |
| 106 |
|
| 107 |
## 6. Leases and drain (oxygenflow#76) |
| 108 |
|
| 109 |
**Lease.** A runner holding a job holds a lease, renewed by its logs and |
| 110 |
a heartbeat. When the lease lapses the job is requeued, as today. |
| 111 |
|
| 112 |
**Drain** is one message in the runner contract (jetstream.md §4) for |
| 113 |
every *planned* departure: |
| 114 |
|
| 115 |
|
| 116 |
|
| 117 |
| SIGTERM, `flow` restart or update | the runner's grace (75 min on Earth) | |
| 118 |
| Kubernetes node drain or eviction | the pod's grace period | |
| 119 |
| Spot reclaim | 30 s (GCP), 2 min (AWS) | |
| 120 |
| Phone off charge, off the allowed network, too hot | the owner's policy (a minute or so) | |
| 121 |
| Browser tab hidden or closing | a few seconds | |
| 122 |
|
| 123 |
On drain the runner decides from the job's learned duration (spec §11): |
| 124 |
|
| 125 |
1. the rest is shorter than the notice: finish the job, then leave; |
| 126 |
2. otherwise: save its checkpoints (§7), upload them, release the lease |
| 127 |
with `drained`, and stop the job. |
| 128 |
|
| 129 |
A drained job is requeued at once, **ahead of new work**, and resumes |
| 130 |
from its checkpoints. A runner that drains a job is not penalised; one |
| 131 |
that drops leases without draining loses placement priority. |
| 132 |
|
| 133 |
## 7. Checkpoints (oxygenflow#77) |
| 134 |
|
| 135 |
A job may declare directories that hold its progress: |
| 136 |
|
| 137 |
```kdl |
| 138 |
job "build" { |
| 139 |
sandbox "rust" |
| 140 |
exec "cargo" "build" "--release" |
| 141 |
checkpoint "/src/target" |
| 142 |
} |
| 143 |
``` |
| 144 |
|
| 145 |
- **Saved** by content (FastCDC chunks, deduplicated, kept for the run's |
| 146 |
life) on drain and every few minutes while it runs (a sandbox setting, |
| 147 |
default 5 minutes, only while the job has run at least that long). |
| 148 |
- **Restored** before a resumed job runs the **same exec again**. Tools |
| 149 |
that skip finished work (cargo's fingerprints, gradle, make, ninja) |
| 150 |
then carry on where they stopped; a tool that does not just starts |
| 151 |
again, so it is never wrong, only slower. |
| 152 |
- **Receipts.** The partial run ends in a receipt with `exit` "drained", |
| 153 |
naming the checkpoint's hash as an output and signed by its runner. The |
| 154 |
resumed job's receipt names that checkpoint among its inputs. So |
| 155 |
provenance says that plex built the first part and mars finished it, |
| 156 |
and both are signed. |
| 157 |
- **Cheap to move inside a cloud:** checkpoints live in the run's store |
| 158 |
(a bucket under Strata for cloud pools), so a new pod in the same region |
| 159 |
restores them quickly. From a phone, the policy (charger, office |
| 160 |
network) keeps uploads cheap. |
| 161 |
- **Checkpoints are code.** A tampered `.rlib` in `target/` ends up in |
| 162 |
the binary. So a job accepts a checkpoint only from a runner of at |
| 163 |
least its trust class (§9), and its receipt says that it used one. |
| 164 |
|
| 165 |
## 8. Hedging (oxygenflow#78) |
| 166 |
|
| 167 |
When a markedly faster runner frees up while a job runs on a slow one, |
| 168 |
and the learned durations say it would finish sooner (counting the time |
| 169 |
to restore the latest checkpoint), start the job there too. The first to |
| 170 |
finish wins; the other is cancelled. |
| 171 |
|
| 172 |
- **Bounded:** one hedge per job, and only where a policy allows it (the |
| 173 |
critical path of a run, or always on owned runners). Never for jobs |
| 174 |
with side effects (deployments, publishing). |
| 175 |
- **No tricky handover:** the slow runner is never interrupted. If the |
| 176 |
fast one fails, nothing is lost. |
| 177 |
- **A free determinism check:** when both finish (a race), their outputs |
| 178 |
are compared; a difference is reported as a non-hermetic job. |
| 179 |
- **Cost-aware in clouds:** starting a bigger instance costs money, so a |
| 180 |
pool may forbid hedging, or allow it only for jobs a person is waiting |
| 181 |
on. |
| 182 |
|
| 183 |
Hedging plus holding briefly for a better runner (which exists) gets most |
| 184 |
of the benefit of a full handover, for a fraction of the work. Build it |
| 185 |
before anything finer (§12). |
| 186 |
|
| 187 |
## 9. Trust classes (oxygenflow#79) |
| 188 |
|
| 189 |
How far to believe a receipt depends on who signed it: |
| 190 |
|
| 191 |
|
| 192 |
|
| 193 |
| `attested` | cloud confidential VMs, vTPM-measured hosts | keyless workload identity (oxygenhub#122) | |
| 194 |
| `owned` | the organisation's registered hosts | the runner's identity, or keyless | |
| 195 |
| `device` | members' phones and laptops | the device's Keystore key, under the member's grant | |
| 196 |
| `anonymous` | browsers not signed in | a key held non-extractable by WebCrypto | |
| 197 |
|
| 198 |
- A sandbox or job may require a minimum (`trust "owned"`). Release and |
| 199 |
publish jobs default to `owned`. |
| 200 |
- A job resumes only from checkpoints made at its class or above. |
| 201 |
- Receipts and provenance record the class of every runner that |
| 202 |
contributed. The trust page shows a run's weakest link. |
| 203 |
- **Spot checks:** a sample of jobs run by `device` and `anonymous` |
| 204 |
runners is run again elsewhere and the outputs compared (with |
| 205 |
oxygenhub#298's independent rebuilds). A runner whose results disagree |
| 206 |
is held for its owner to look at. |
| 207 |
|
| 208 |
## 10. Phones and browsers |
| 209 |
|
| 210 |
**Phones** (oxygenhub-android#8): one tap registers the phone as a |
| 211 |
WASM-tier runner, as a foreground service, scoped like any runner. The |
| 212 |
owner's policy says when it may run: on charge, on these networks (the |
| 213 |
office Wi-Fi), under a temperature, within a storage budget. Leaving |
| 214 |
those conditions drains it (§6). The runner itself is Flow's runner code |
| 215 |
built for Android through the app's Rust core (rust-core, uniffi), with |
| 216 |
wasmtime for the tier. The full Linux runner in Android's Linux terminal |
| 217 |
(#9) comes later. |
| 218 |
|
| 219 |
**Browsers** (#80): a page on the hub turns a tab into a WASM-tier runner |
| 220 |
in a Web Worker: WASI through a shim, OPFS (the browser's private file |
| 221 |
storage) for files, uploads as for any runner. Limits: about 4 GB of |
| 222 |
memory, threads only on a cross-origin-isolated page, throttled or |
| 223 |
stopped in a background tab (a drain). This is mostly a demonstration of |
| 224 |
handover and spot checks on the least reliable runner there is, not |
| 225 |
capacity. |
| 226 |
|
| 227 |
## 11. The first demonstration |
| 228 |
|
| 229 |
A pipeline over oxy-core: |
| 230 |
|
| 231 |
1. `build-wasm-tests` (microvm, any x86_64 host): `cargo test |
| 232 |
--target wasm32-wasip1 --no-run`, outputs the test modules. |
| 233 |
2. `test-wasm` (wasm, `runs-on "android"`, depends on 1): runs each |
| 234 |
module under the WASM tier, on the phone. |
| 235 |
|
| 236 |
The script: start the run; the phone takes `test-wasm`; switch the |
| 237 |
phone's Wi-Fi off halfway (a drain under its policy); the job goes back |
| 238 |
to the queue and plex-1 takes it in its WASM tier; the run page shows |
| 239 |
which runner did what, with the receipts and their trust classes. Then |
| 240 |
the same with a pipeline that has a checkpoint, to show a job resuming |
| 241 |
rather than restarting. |
| 242 |
|
| 243 |
## 12. Order of work |
| 244 |
|
| 245 |
1. **WASM tier** (#75), in `flow` on Linux runners. |
| 246 |
2. **Phone runner** (oxygenhub-android#8) on that tier, with its policy. |
| 247 |
3. **Leases and drain** (#76), with requeue ahead of new work. |
| 248 |
4. The demonstration (§11), job-level: drain and requeue. |
| 249 |
5. **Checkpoints** (#77): resume within a job; receipts that say so. |
| 250 |
6. **Trust classes** (#79), before anything other than our own runners |
| 251 |
joins. |
| 252 |
7. **Hedging** (#78), then measure on our own CI: queue and run times |
| 253 |
with and without it. |
| 254 |
8. **Browser runners** (#80). |
| 255 |
9. **Finer pieces,** only if the measurements call for them: test |
| 256 |
sharding and per-crate builds (#29), so that a long job is many short |
| 257 |
ones a better runner can take over between. |
| 258 |
|
| 259 |
Cloud and Kubernetes pools come through Jetstream (jetstream.md §9, |
| 260 |
oxygenflow#33), and get drain and checkpoints as they arrive. |
| 261 |
|
| 262 |
## 13. Open questions |
| 263 |
|
| 264 |
- Checkpoint retention: the run's life, or kept as a cache for the next |
| 265 |
run of the same job (a remote cache, which would then need the same |
| 266 |
trust rules)? |
| 267 |
- How long may a drained job wait for a runner of its trust class before |
| 268 |
it falls back to restarting from nothing on any runner? |
| 269 |
- Hedging costs compute: a per-pool budget, or a per-organisation one? |
| 270 |
- A phone's battery and heat: are the owner's policy limits enough, or |
| 271 |
should the runner also cap its own CPU use? |