OXYGENHUB

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?