Notes on building k7d · Part 5
A snapshot tree for agents: fork, protect, prune, rollback
Once forks are cheap, the problem moves from latency to lifecycle: keeping N copies under a RAM and disk budget.
Part 4 made forking cheap. This part is about what cheap forking does to your system design. When an environment copy cost 45 seconds, nobody made too many. At 100 ms, an agent doing tree search will happily fork 300 times, keep every branch "just in case", and run the box out of memory. The failure mode moves from latency to lifecycle, and lifecycle is exactly what you can't delegate to the agent — the agent is the party you can't trust to clean up after itself.
So k7d's daemon doesn't expose "make me a VM". It exposes a snapshot tree with budgets, because a tree is the shape the workload already has.
Search over environments is a tree, not a list
Watch what any tree search or RL run actually does with environments: fork a branch, try something, fork again from the promising state, abandon the dead end, go back to an earlier checkpoint and try the other door. The natural data structure has parents, children, live nodes (running VMs or clusters) and suspended checkpoints, some of which must never be deleted because they're the best thing found so far.
The API is JSON-lines over a Unix socket (/run/k7d/k7d.sock) — deliberately boring, so wiring it into a trainer is an afternoon, not an integration project. The verbs map one-to-one onto the moves in the picture:
| You want to… | Call |
|---|---|
| Start from a warm VM or live cluster | tree_create / tree_create_cluster / tree_adopt_cluster |
| Open N parallel rollouts from one checkpoint | tree_fork_batch |
| Try again from an earlier node without destroying it | tree_rollback |
| Pin a winner so budget pressure can't kill it | tree_protect |
| Drop a losing subtree | tree_prune |
| Enforce RAM/disk caps now | tree_auto_evict |
Two of these deserve a closer look, because they encode the division of labor that makes the whole thing safe to hand to an agent.
Budgets, LRU, and the one rule the daemon never breaks
tree_fork_batch is the throughput verb: N children from one checkpoint under a single coordinated pause, so the expensive part (freezing the source, capturing dirty pages) is paid once rather than N times. That amortization is why 50 cluster forks cost ~4.1 s total (~82 ms each) instead of 50 sequential ~105 ms operations, and it's the natural fit for GRPO's "give me the whole group at once".
tree_auto_evict is the survival verb. You give the daemon a RAM and disk budget; when the tree grows past it, the daemon evicts — suspending or discarding the least-recently-used unprotected nodes until the tree fits. The ordering rules are what you'd hope: protected nodes are exempt, period; live children pin their ancestors' pages (CoW means a parent's memory is load-bearing for its children); among the evictable, least-recently-used goes first. Your training loop owns rewards and policy. k7d owns environments and budgets. Neither trusts the other with its half, and that split is the design.
The rule the daemon never breaks: eviction must never delete a protected node. That sentence sounds trivially easy to implement, and it is — which is exactly why I didn't trust it. "The eviction logic had an off-by-one and deleted the winner after six hours of search" is not a bug you want to discover empirically, so the tree's bookkeeping model — budgets, protection, LRU victim selection — is extracted to Lean and machine-checked. That story is Part 6.
Why byte-identical starts matter for GRPO
GRPO and its group-relative cousins compare rewards within a group: N policies attempt the same task from the same start, and the learning signal is who did better. The whole method rests on "the same start" being literally true. If member A begins from a colder cache, a different etcd revision, or a half-ready Deployment than member B, the reward gap between them is environment noise, not policy signal — and your gradient chases it.
A tree_fork_batch(N) group starts from N copies of the live machine: same memory, same disk, same in-cluster TLS sessions, same kube-apiserver state, byte for byte. Divergence after that is attributable to exactly one cause: what the policy did. The tree is where that property becomes an API rather than a one-off benchmark.
Spelled out as the recipe from the README:
- Boot the scenario once — your cluster, your Helm chart, your eval harness's starting state — and wait until it's exactly where every rollout should begin.
- Root a tree at that checkpoint.
- Per group / search step:
fork_batch(N)→ run N policies against N copies → score →protectwinners,prunelosers → letauto_evicthold the budget. - Roll forward from a protected winner when the next generation should start from a better state, or
rollbackwhen it shouldn't.
Walking the demo
The repo ships a scripted version of that loop in examples/cluster-tree-search/, and it's honest about what it is: not SWE-bench, not a training run, no GPU. A driver (run_demo.py) plus a thin JSON-lines client (k7d_client.py, ~a screenful — a template for wiring your own trainer, not a product SDK). It forks N branches from a live checkpoint, scores a trivial reward, protects the winner, prunes the losers, and prints the fork wall-clock so you can see the claim on your own hardware.
cd examples/cluster-tree-search
python3 run_demo.py --mode busybox --branches 4
# density claim:
python3 run_demo.py --mode busybox --branches 50
Two modes, deliberately different in weight. busybox creates a fresh 3-VM cluster and batch-forks it — the fast path; a 4×3-VM CoW batch measured ~262 ms on the pinned node. inner-k3s adopts a live 3-node k3s cluster that's being actively churned (a Deployment scaling, ConfigMap and pod storms) and batch-forks that, scoring each branch by whether the forked API server answers /readyz and its Deployment is Ready on the fork's own bridge — the headline ~104 ms path from Part 4, exercised end to end. The demo prints the wall-clock; the enforced budgets stay in the integration tests, so a demo can't quietly redefine the claim.
One habit of the API worth naming: tree_adopt_cluster means k7d can take over a cluster that's already running and root a tree at it — you don't have to have planned your fork tree before booting the world. Boot, get it right once, adopt, branch.
So the tree hands an agent real power over real machines — including the power to have the daemon kill VMs on its own under budget pressure. Part 6 is about why I trust it to do that: Kani on the unsafe memory math, and a Lean proof that eviction can never eat the winner.