August 11, 2026 · BariBariGood

Why agents need simulator leases

The first time I ran two coding agents against the same Mac, they found the same booted simulator within a minute. One was mid-way through a login flow; the other decided the sim was stale, shut it down, and booted a different one. The first agent kept tapping coordinates on a screen that no longer existed and confidently reported the login test as failed. Nothing was broken except the assumption that a simulator belongs to whoever found it.

Simulators are shared mutable state with no access control. simctl happily lets any process boot, erase, or type into any device. Humans coordinate over Slack; agents don't coordinate at all. Once you run more than one, you need an owner.

Locks are the obvious answer, and the wrong one

My first fix was a lock file per UDID. It failed in every way lock files fail: an agent crashed holding the lock and everything wedged; an agent's shell died and the lock leaked; two agents on different machines couldn't see each other's locks at all. The failure mode of a lock is a stuck fleet, and agents crash a lot.

A lease is a lock with a clock and a line. It is TTL-bounded: if the agent dies, the claim expires and the simulator returns to the pool by itself. It is queued: a busy target means a FIFO position, not a spin loop. And it is granted by the one process that actually owns the state — a daemon on the Mac — so it works for N agents on N machines:

POST /v0/leases
$ curl -s -X POST mac-host:7433/v0/leases -d '{
    "labels": ["ios26"],
    "agent_id": "claude-1",
    "ttl_seconds": 300,
    "reset": "snapshot:logged-in"
  }'
{"id":"lse_9f2","target_udid":"…","state":"active",
 "expires_at":"…","queue_position":0}

Every mutating call after that carries the lease ID, and the daemon rejects calls against targets you don't hold. That single check is most of the value: the wrong-window class of bug is gone, not mitigated.

Expiry needs cheap handovers

Leases only work if losing one is cheap. If a fresh simulator costs a cold boot, agents hoard leases forever and you're back to locks with extra steps. So the daemon keeps a warm pool: idle sims are parked with SIGSTOP — a stopped process tree is unschedulable, so a parked sim costs ~0 host CPU no matter what its daemons want to do — and thawed with SIGCONT on lease grant.

Lease to live simulator

M3, macOS 26
first boot~29 s
cold boot~7 s
thaw from warm pool~0.28 s

At ~0.28 s lease-to-live, giving a simulator back is painless, so TTLs can be short, so the queue actually moves. The mechanisms reinforce each other; neither is much good alone.

Determinism closes the loop

The last piece is what the next agent inherits. A lease can declare a reset — erase, or restore a named snapshot — applied automatically at release. Golden images stamp out slimmed sims (~0.75 GB vs ~5 GB stock) in seconds, so "a clean iPhone 17 Pro with the app installed and a logged-in account" is a starting state, not a 10-minute setup script. Every retry starts from the same bytes.

The philosophy

The design rule behind all of this: everything stateful lives in the daemon; clients stay thin. Agents are terrible at distributed coordination and great at making API calls, so the coordination problem should live in one process per Mac that owns the registry, the lease table, the warm pool, and the journal — and the agents should just ask. It's the same reason databases have servers instead of every client fencing over the same files on an NFS mount.

And because the referee sees every mutating op, it can journal them — who held the lease, what they did, what the screen looked like. Once no two agents can trip over each other, the evidence of what each one did is suddenly trustworthy. That journal became the run primitive in v0.5.0, but it started here: leases aren't a scheduling feature, they are what makes anything an agent reports about a simulator believable.