Handover: jobs that outlive their runner (design; refs #75 #76 #77 #78 #79 #80) 39e737fbc7a9
docs/handover.md, from a design conversation with Andy: what Flow has already (jobs as packets, receipts that chain across runners, requeue), the decision to move progress rather than processes (no VM live migration: a snapshot keeps its vCPUs and cannot leave a runner that is gone), runner kinds from hosts to phones and browsers, the WASM tier, leases and drain, checkpoints with receipts, hedging, trust classes, the first demonstration and the order of work.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 2de002e912dc
- When
- · 2026-10-25 12:03 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
39e737fbc7a9fc3df8972b69542a287ecf8f781e6fb00b47b59cd14a7466c675- Depends on
- e0918b887850
- Depended on by
- 28cc603f737d
- In bookmarks
- main
- In tags
- v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
- Edits
- 1 atom, 1 tree operation, 1 file
Browse the tree at this change
added docs/handover.md +271 −0
| 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 | + | | Kind | Tiers | Typical trust (§9) | Leaves by | |
|
| 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 | + | ||
| 90 | + | Spec §4.1 names it; it is not built (`runtime "wasm"` is refused today). |
|
| 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 | + | | Trigger | Notice | |
|
| 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 | + | | Class | Who | Key | |
|
| 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? |