Skip to content

Blog · Companion to the bake-off writeup

How we ran a 23-day Ethereum client bake-off with Claude

That post was about the clients. This one is about the machine that tested them: the agent orchestration model, the harness we built to keep ourselves honest, and what actually breaks when a benchmark runs for three weeks on a shared host with an AI in the driver's seat.

Up front, honestly: this was AI-driven, not AI-unsupervised. Every destructive action against the live node was gated behind an explicit human confirmation, every result was committed under conventional-commit review, and no agent could merge its own pull request. The interesting claim here isn't “the AI did it alone” — it's that the right division of labor between an agent and an operator let a 23-day, disk-and-timing-sensitive benchmark run to completion without a person watching it sync.

TL;DR

Two clocks, then a third nobody expects

Node wall-clock (detached systemd) and agent wall-clock (event-driven wakeups) are the obvious bottlenecks. The real one is agent context — solved by pushing conclusions down to the data and keeping durable state in small files.

Three-tier agent hierarchy for context economy

Opus orchestrator (plans, reviews every diff) → fresh Sonnet builders (implement, report back a summary) → delegate models (cheap and sandboxed work).

Non-negotiable governance, not vibes

One candidate at a time, a 72-hour cap, destructive actions gated behind explicit human confirmation, only a human merges.

The headline numbers hide operational limits

ethrex is the fastest cold-sync in the field but is not production-ready as tested: a 26-minute/132-block restart gap stalled, longer measured gaps caused a full re-snap, and its datadir kept growing at tip. besu did sync successfully; its pruned re-run exposed fragility after a prolonged outage of the pinned Prysm version.

At a glance

Four real incidents hit during the campaign, all fixed or explicitly documented.

nethermindFixed

production-viable

13.3h silent stall — head frozen at block 4,651, 0 peers, everything else looked healthy

Root cause: P2P bind pinned to loopback (Network.LocalIp=127.0.0.1)

Resolution: Advertise the real external IP

besuFragile

to a prolonged CL outage

Mid-sync deadlock — downloader thread died, process stayed alive and kept answering RPC

Root cause: Stale pinned CL (prysm v7.1.5) stalled the beacon ~28h; snap-sync pivot aged out of the ~25-min servable-state window

Resolution: None available (upstream CL issue) — keep CL binaries current; harness gained a stall-watchdog

ethrexNot production-ready

as tested — young client, may improve

Gaps through 23 min / 124 blocks resumed; a 26 min / 132 block gap stalled, and measured 1.5–2h gaps discarded state and re-snapped (~2h). The un-pruned datadir also grew at tip (~286 → ~467 GiB)

Root cause: Old head ages out of the ~128-block servable-state window; beyond it the head can freeze and longer gaps can trigger a full snap instead of importing the gap

Resolution: None — inherent to current design (v19.0.0)

erigonNo-sync

on this host/CL combination

Head froze a few thousand blocks behind tip; consensus stayed is_optimistic=true indefinitely

Root cause: Genuine gap-close deadlock, erigon3 OtterSync vs. checkpoint-synced Prysm — not resource starvation

Resolution: None — terminated per operator decision, recorded as a no-sync

The durable control loop

Human operator — approves destructive steps
Claude orchestrator — starts and resumes runs
Detached tmux driver
Bake-off harness → systemd EL/CL services + samples, verdicts, run artifacts
Small durable state: results, queue, handoff
fresh session recovers context
Claude orchestrator — starts and resumes runs

The artifacts carry the campaign forward; a new orchestrating session reads the small durable state instead of reconstructing a run from raw logs — closing the loop back to the orchestrator.

23-day campaign — key dates

2026-06-22

Campaign starts, Stage A triage begins

2026-06-26

Stage-A installer fixes shipped

2026-07-01

besu completes un-pruned sync

2026-07-05

besu pruned re-run abandoned, deadlocked twice

2026-07-06

CL sweep vs ethrex anchor, 5 CLs

2026-07-08

CL cross-check vs geth anchor

2026-07-10

ethrex restart-cliff bisected, geth 52h resume verified

2026-07-12

Installer / config correctness fixes shipped

2026-07-13

nimbus_eth1 72h capped run completes

2026-07-14

Campaign ends, harness and results docs shipped

Every date is a shipped fix or a completed measurement run, sourced from CLIENT_BAKEOFF_RESULTS.md and the repo's merged-PR history — not reconstructed from memory.

The shape of the problem

Benchmarking a sync client is deceptively expensive:

  • It's slow. A single mainnet sync ranges from ~2 hours (ethrex, snap) to never finishes in three days (the full-sync-only clients). Each candidate got a 72-hour cap.
  • It's sequential. One shared host, one execution slot, one consensus slot — geth and nethermind side by side would contend for CPU, IO, and peers, so candidates run strictly one at a time.
  • It's easy to measure the wrong thing. A client that “installed and followed the chain” can be silently broken (0 peers, frozen head); a datadir means nothing if it's running in archive mode; a footprint sampled mid-compaction over-counts.
  • It's destructive. Measuring the next client means wiping the last one's datadir on a shared box that also runs other people's work.

Multiply that across the whole supported field of clients and three weeks and you have a task defined less by any single hard step than by sustained correctness — the discipline to run the same careful protocol dozens of times, preserve the terminal measurement before teardown, and never let a shared-host quirk masquerade as a client property.

The orchestration model

The core design choice: decouple node wall-clock from agent wall-clock, and decouple durable state from agent context. Get those two right and a three-week campaign stops needing a three-week attention span.

1. The node runs; the agent doesn't watch it run

Every client runs as a native systemd service (eth1.service, cl.service, no Docker) in a detached tmux session — a sync proceeds for 72 hours whether or not any Claude session is alive. The orchestrating session did die mid-run more than once (once to an out-of-memory event); the systemd unit and its sampler kept going, and a fresh session picked the campaign back up from durable state with nothing lost. Instead of polling logs, the agent armed event-driven watchers that fire one notification on a terminal condition, so the orchestrator slept until something decision-worthy happened.

2. Three tiers of agent, by cost and capability

Not every sub-task deserves the strongest, most expensive model:

Orchestrator / reviewer — Claude Opus 4.8plans, reviews every diff, writes durable state
Builder — fresh Sonnet subagentone task, reports a summary back
Builder — fresh Sonnet subagentone task, reports a summary back
Delegates — cheaper / sandboxed modelscheap read-only work, routed via wrapper binaries
summary only, not full context — returns to the orchestrator

Orchestrator / reviewer

Claude Opus 4.8

Planned the queue, made the judgment calls, reviewed every diff, wrote the durable state. Did not hand-write most client code.

Builders

fresh Claude Sonnet subagents

Implemented fixes against a written brief, reported a short summary back — keeping the bulk of the tokens out of the orchestrator's context.

Delegates

cheaper / sandboxed models

Cheap read-only research and review, and any sandboxed work, routed through wrapper binaries with auth, fallback, and telemetry.

The point is context economy. A builder subagent can read ten thousand lines of client source, produce a three-line commit, and return “done, here's the diff” — the orchestrator never has to hold those ten thousand lines. It reviews the diff, not the investigation.

3. Durable state is the backbone

The orchestrating agent's context window — not node wall-clock — is the real scaling bottleneck of a long agent-driven campaign.

The harness had already solved node time. But every status check, every “is it stalled?” pulled raw journalctl, du, and RPC output into the context window, and that filled up in hours, not weeks. The fix was architectural:

  • Push conclusions down to where the data lives. Each sample collapses to a couple of flags in env.txtfully_synced=yes after two consecutive clean samples, or (with the stall-watchdog armed) .stalled once bounded restarts are exhausted — so the agent reads a file, not a log.
  • Keep durable state small and in files. Results, governance rules, the queue, and a live self-handoff note live in a handful of markdown files — a mid-campaign context clear becomes a non-event.
  • Keep transient investigation off the context path. Logs, probes, and sample dumps are ephemeral: computed, summarized, dropped — never carried.
SYNCING
SYNCED

2 consecutive clean samples

CAPPED

window elapses, still not synced

RESTARTING

no-progress streak hits threshold (opt-in watchdog) — loops back to SYNCING, or falls through to STALLED if the restart budget runs out

STALLED

restart budget exhausted (opt-in stall-watchdog) after block number or head slot fails to advance; the harness flags the row instead of spinning to the 72-hour cap

This is what's actually implemented, not a peer-aware state machine: bakeoff_is_synced() checks sync_distance, is_optimistic, and el_offline together — already enough to avoid trusting eth_syncing=false alone — but there's no peer-count check anywhere, and the stall-watchdog is opt-in. nethermind's 13.3h loopback stall (see the table above) predates the watchdog: the harness correctly never reported it synced, but nothing flagged the run as stuck rather than still syncing — that gap is exactly what motivated building the watchdog afterward.

4. Governance the agent could not override

  • One candidate at a time. No batching. Ever.
  • 72-hour cap per candidate; footprint is the last near-cap sample, never the peak.
  • Destructive data-cleans are gated behind explicit confirmation, and wiping the live shared node always required a fresh human go-ahead.
  • Conventional Commits, new commits only, never a force-push to master; secrets stayed in protected local files and were never committed or exposed to agent context.
  • An agent cannot merge its own pull request. A human does that.

The harness

The measurement machinery lives in test/bakeoff. It's plain bash — deliberately, so it has no runtime that can drift out from under a systemd service:

  • run_bakeoff.sh sequential orchestrator with a resume guard: a killed campaign restarts where it left off, never re-runs finished candidates.
  • run_candidate.sh single-candidate runner: reset → install → cap → sample. Owns the observation loop and captures a final footprint on synced, capped, stalled, and install-error paths before teardown. Preflight aborts such as insufficient disk exit before sampling starts.
  • lib.sh shared probe/sample library: sync probes, the sample writer, the disk snapshotter, and the config-optimality gate.
  • apply_resource_caps.sh systemd CPUQuota/MemoryMax caps so a heavy sync can't starve co-resident workloads on the shared host.
  • summarize.sh turns per-run artifacts into the results table, split by whether the config was verified optimal.
  • run_anchor_rotation.sh the anchor-preserving mode used for the consensus-client sweep.

The harness pipeline

Candidate manifest → run_bakeoff.sh (walks one candidate at a time)
run_candidate.sh: hard-reset shared services → install → apply resource caps
run_candidate.sh sampling loop → verdict (SYNCED / CAPPED / STALLED / install error)
Snapshot disk, before teardown
summarize.sh → results table

The config-optimality gate, and why it needed six bug-fixes

Early on we corrupted our own results by recording a footprint before confirming the client was in its most disk-efficient mode: reth at its defaults runs a ~2.8 TiB archive node, and we nearly recorded that as “reth's footprint” when the pruned number (--full) is ~1.2 TiB. A benchmark that measures your own misconfiguration is worse than no benchmark — it just looks authoritative.

So the harness grew a config-optimality gate: before trusting a footprint, it inspects the actually-running config and stamps every row config_optimal=yes|no; summarize.sh quarantines non-optimal rows in a “superseded” section. The gate needed six bug-fixes across three review rounds before we trusted it — every one the same species (“the flag I asserted on doesn't match the real generated config”) — the exact failure mode the gate exists to catch, turned on itself.

Anchor-preserving mode: don't re-sync the world five times

The consensus-client matrix holds the execution client constant and cycles the CL. Naively that's five full EL re-syncs. Anchor-preserving mode keeps one already-synced execution client running and cycles only the CL service per candidate, purging just the consensus datadir between runs: five CL candidates, one EL sync. We ran the sweep twice — against an ethrex anchor and a geth anchor — to prove the EL/CL decoupling empirically. The heavyweight and lightweight tiers both reproduced on both anchors — with one nuance: the lodestar↔lighthouse order flipped between anchors (geth: lodestar < lighthouse; ethrex: lighthouse < lodestar), though both stayed in the same smallest tier, where the gap is small and measurement-window-sensitive.

Two harness bugs that nearly cost us data

  • The detached-shell landmine (SIGTTIN). An install step shelled out to a version-check command. Run from a detached tmux session in a non-foreground process group, that read raised SIGTTIN against a tty it didn't own — which stops (not kills) the whole subtree — and hung a run for 90 minutes. Fix: redirect stdin from /dev/null on unattended invocations.
  • The measurement that vanished at the cap. The disk snapshot was taken only on the synced success branch. When a slow client hit the 72-hour cap, the script fell through to teardown — which wiped the datadir — and snapshotted after. Fix: snapshot every terminal run path after installation and before teardown; preflight aborts still exit before sampling. The cap path is the one you forget, and it's the one a slow client actually takes.

What we'd tell the next person

  • The third clock is the real limit. Node wall-clock and agent wall-clock are solvable with infrastructure; agent context only scales if you push conclusions to the data and keep durable state in small files.
  • Measure on every exit path, before you destroy anything. Success is the easy path. The cap and the error paths are where your data quietly disappears.
  • Gate your benchmark on config, not just on outcome. Stamp every number with “was this the client's best mode?” or you will eventually publish a measurement of your own mistake.
  • Give an agent a job and a fence. The agent owns the tedious, sustained correctness; the human owns the few irreversible levers.

Honest limitations

This is a real benchmark, not a lab result. It ran on a shared, semi-production host (12 cores, ~62 GB RAM, co-resident workloads) — representative of how many people actually run nodes, but with contention the numbers can't fully isolate. Each client was measured on one run at a pinned version, so a single result is a data point, not a distribution. An AI-in-the-loop campaign also carries its own risk surface, which is exactly why the governance fence above was non-negotiable rather than advisory.

Reproduce it

The harness is in the repo and the data is committed:

Read next