Leasing Runtime Environments to Parallel Agents: Port Ranges, DB Schemas, and Dev Servers, One Per Agent
When Antigravity 2.0 runs agents truly in parallel, they collide on the same port, database, and dev server. Here is a lease-based design that hands each agent its own isolated environment, with implementation and measured numbers.
I once handed three agents three different parts of the same Next.js app at the same time.
One took the UI components, one took the API routes, one took a schema migration. Run them in parallel on the Antigravity 2.0 desktop and, on paper, that is three times faster.
It went the other way. All three tried to grab localhost:3000 and threw EADDRINUSE in a loop. The migrations fought over the same database and stalled on locks. One agent's test wiped the seed data another agent depended on. Measured against the wall clock, the parallel run came out about 1.4x slower than doing the work serially. Parallelism that made things slower — an odd sight.
The cause was not compute. It was shared, stateful side effects: the port, the dev server, the database, the migration, the file watcher. There is only one of each, and three agents reached for them at once. True parallel execution surfaces every single-instance resource without mercy.
This article lays out the answer I arrived at while running several personal apps side by side as an indie developer: lease one runtime environment to each agent, with the implementation to back it.
What "parallelism that isn't faster" actually is
Parallel-agent failures nearly always land in one of four layers.
Layer
What collides
Symptom
Network
Multiple dev servers fight for one port
EADDRINUSE :::3000
Database
Same tables/rows rewritten; migrations race
Lock waits, deadlocks, data loss
Filesystem
Concurrent writes to lockfiles, build output, cache
Corrupt pnpm-lock.yaml, half-built artifacts
OS resources
inotify watches and file descriptors exhausted
ENOSPC (watch limit), unresponsive HMR
The shared structure is always "several actors touch a single-instance resource with no arbitration." When one human develops alone, this never surfaces, because you don't start two dev servers at once. The moment agents go parallel, that hidden assumption breaks.
So the fix is not to make the agents smarter. It is to split the environment: give each agent one runtime that overlaps with no other. That is the idea behind a lease.
What a lease actually hands over
A lease is a bundle of non-overlapping resources loaned to an agent for a while. As a minimum, I fold five things into a single lease.
Resource
How it's assigned
Collision it removes
Port range
Deterministic offset from the agent index
Contention over dev server, API, DB proxy
DB schema
A dedicated schema cloned from a template
Row corruption, migration races
Working directory
A separate git worktree
File, lockfile, and artifact clashes
Env file
An .env.agent written with the lease values
Mixed-up configuration
Lease ID
A unique key that anchors reclamation
Resource leaks after a crash
The key is deriving all of this deterministically from the agent index. Grabbing a random free port looks clever, but for reasons below it is harder to operate in parallel.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦An allocator that leases a dedicated port range, DB schema, and dev server to each agent, including how the lease is reclaimed
✦Why schema-per-agent provisions faster than database-per-agent (about 80ms vs 900ms), and how to skip migrations by cloning a pre-migrated template
✦The counter-intuitive reason a deterministic port beats a random free port in parallel operation
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
First, an allocator that takes an agent index and assembles one lease: it computes the port range, creates the dedicated schema, and writes the env file.
// lease.mjs — lease one runtime environment for a single agentimport { writeFileSync } from 'node:fs';import pg from 'pg';// Ports consumed per agent. Reserve dev(3000s)/api(4000s)/db-proxy(5000s)// as one band. Ten leaves room for peripheral tooling.const PORTS_PER_AGENT = 10;const BASE = { dev: 3000, api: 4000, dbProxy: 5000 };// Derive the port band deterministically from the agent index (0,1,2,...).// Fixing the index means the same ports come back on every restart.function derivePorts(agentIndex) { const offset = agentIndex * PORTS_PER_AGENT; return { dev: BASE.dev + offset, // e.g. agent0=3000, agent1=3010 api: BASE.api + offset, dbProxy: BASE.dbProxy + offset, };}// Clone a dedicated schema from a "pre-migrated template."// Do not run migrate every time. This was the biggest bottleneck to parallelism.async function provisionSchema(client, schema, template = 'tpl_migrated') { // The identifier is concatenated into SQL, so allow only [a-z0-9_]. if (!/^[a-z0-9_]+$/.test(schema)) { throw new Error(`unsafe schema name: ${schema}`); } // Clean up any leftover, then clone the template structure. await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`); await client.query(`SELECT clone_schema('${template}', '${schema}')`); return schema;}export async function acquireLease(agentIndex, agentId) { const ports = derivePorts(agentIndex); const schema = `agent_${agentIndex}`; const client = new pg.Client({ connectionString: process.env.ADMIN_DATABASE_URL }); await client.connect(); try { await provisionSchema(client, schema); } finally { await client.end(); } // Inject the schema via search_path. App code keeps its table names as-is. const dbUrl = `${process.env.APP_DATABASE_URL}?options=-c%20search_path%3D${schema}`; const env = [ `PORT=${ports.dev}`, `API_PORT=${ports.api}`, `DB_PROXY_PORT=${ports.dbProxy}`, `DATABASE_URL=${dbUrl}`, `AGENT_ID=${agentId}`, `LEASE_INDEX=${agentIndex}`, ].join('\n'); writeFileSync(`.env.agent.${agentIndex}`, env + '\n'); return { agentIndex, agentId, schema, ports, envFile: `.env.agent.${agentIndex}` };}
Using search_path is the crux. The application can keep writing table names like users and orders, and they resolve inside whichever schema you injected at connect time. You separate the data container without changing a single line of the code you hand the agent.
Implementation 2: clone a pre-migrated schema from a template
clone_schema is the trick that avoids running migrations every time. Keep one tpl_migrated schema that you have already migrated, and hand each agent a copy of its structure.
-- Clone the structure of a pre-migrated template into another schema.-- Carries over table definitions, constraints, and sequences; starts empty.CREATE OR REPLACE FUNCTION clone_schema(src text, dst text)RETURNS void AS $$DECLARE obj record;BEGIN EXECUTE format('CREATE SCHEMA %I', dst); -- Copy table structure (constraints and defaults). INCLUDING ALL is the point. FOR obj IN SELECT tablename FROM pg_tables WHERE schemaname = src LOOP EXECUTE format( 'CREATE TABLE %I.%I (LIKE %I.%I INCLUDING ALL)', dst, obj.tablename, src, obj.tablename ); END LOOP;END;$$ LANGUAGE plpgsql;
With INCLUDING ALL, primary keys, unique constraints, defaults, and indexes all carry over. A container equivalent to a fully migrated state, from a single copy statement. In my setup this clone finishes in about 80ms on average. Creating a fresh database with createdb and re-running migrations, by contrast, takes around 900ms even for a small schema. The more agents you add, the more that gap lands directly on startup time.
Counter-intuitive point 1: schema isolation is faster than database isolation
At first I assumed "one database per agent" would be cleanest — fully independent namespaces, easy to separate privileges. But measured, it became the startup bottleneck.
Approach
Isolation strength
Provision time (measured)
Migrations
database-per-agent
Strongest (separate DB)
~900ms+
Needed per DB, every time
schema-per-agent
Strong (separate schema)
~80ms
Skipped via template clone
Shared DB, agent column on rows
Weak (logical only)
~0ms
None, but corruption risk remains
Agents rebuild their environment constantly: fail and retry, switch tasks mid-way. If each rebuild costs 900ms, startup eats the gains of going parallel. Isolation does not need to be as strong as "separate database"; "separate schema" is enough — that is what actually working through it taught me. Adding just an agent_id column to a shared DB is the fastest of all, but a mistaken DELETE from one agent hits everyone, so I ruled it out. As a balance between isolation strength and startup speed, schema-per-agent sits in the middle.
Counter-intuitive point 2: a random free port makes operation harder, not easier
"Just grab any unused port dynamically" is the natural instinct. I did that first. But with parallel agents it makes cleanup harder.
Agents restart dev servers repeatedly. When a previous process lingers as a zombie, the next start escapes to a different random port, and the cleanup target changes every time. Who is using which port becomes non-deterministic, and the health-check target won't settle either.
With a deterministic port, cleanup is one line.
# Reliably clean up whatever is left on agent 2's band (3020).# Because the port is deterministic, the cleanup target is always the same.lsof -ti:3020 | xargs -r kill -9
Same agent index, same 3020 for the dev server, every time. Monitoring and cleanup both get a fixed target. "Deterministic and traceable" is easier to automate than "random to avoid collisions." This was the opposite of what I expected going in.
Implementation 3: reclaiming the lease — don't leak crashed resources
A quietly important part of parallel operation is reclaiming the lease. When an agent crashes, its schema and ports are left dangling. Left alone, schemas pile up and ports fill.
Keep one mechanism that sweeps expired leases, anchored on the lease ID.
// reap.mjs — reclaim an expired lease (run periodically or at startup)import pg from 'pg';import { execSync } from 'node:child_process';const PORTS_PER_AGENT = 10;const DEV_BASE = 3000;export async function reapLease(agentIndex) { // 1) Drop the schema const client = new pg.Client({ connectionString: process.env.ADMIN_DATABASE_URL }); await client.connect(); try { await client.query(`DROP SCHEMA IF EXISTS agent_${agentIndex} CASCADE`); } finally { await client.end(); } // 2) Clean the band (deterministic port means the target is fixed) const devPort = DEV_BASE + agentIndex * PORTS_PER_AGENT; try { execSync(`lsof -ti:${devPort} | xargs -r kill -9`, { stdio: 'ignore' }); } catch { // Nothing to clean is fine; keep reclaiming even if this step fails }}
Treat a lease as something that must always be returned once borrowed, and resources stop growing monotonically even during long automated runs. Design this in early, and you can run agents for three days straight without the environment silting up.
Operational details the docs don't mention
A few small things I only learned by doing that make a real difference.
You hit the inotify watch limit first. Start N dev servers and each one watches node_modules, consuming inotify instances. On Linux the default limit is low, and I reached ENOSPC (the watch limit, not disk) at three to four agents in parallel. Narrow each agent's watch scope to its own worktree, or raise fs.inotify.max_user_watches.
Run migrations once, outside lease acquisition. Update the tpl_migrated template exactly once, before starting any agent. Letting an agent call migrate inside its lease defeats the point of cloning and races migrations against each other. Migrations before the lease, cloning inside it — keep the roles separate.
Make the lease ID a log correlation key. In parallel, you lose track of which agent's which attempt a log line belongs to. Stamp AGENT_ID and LEASE_INDEX on every log line and you can trace failures afterward. The debuggability of parallel operation is largely decided right here.
Where to start
You don't need to build all of this at once. In order of how much the collision hurts, I'd suggest:
Deterministic port allocation first. Just making EADDRINUSE disappear changes how parallelism feels. Then schema isolation with template cloning, which lowers the risk of data loss and makes retries cheap. Reclamation last — introduce it when you step into long-running operation, and that's soon enough.
Parallel agents are decided less by cleverness and more by "environments that don't overlap" — that's the sense I reached the long way around. Next time you run two or more agents at once, try pinning the ports first. That's the first fork in the road.
I hope this helps with your own design decisions. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.