Five Coding Agents, Five Branches, One Database: Giving Each Task Its Own Emulator
💡 Tool Tip: Docker Compose Generator, YAML to JSON, JSON Formatter
On October 28, 2026, the Azure Cosmos DB blog published a post by Jay Gordon that is not about writing code at all. It describes how parallel coding agents fail. The setup is concrete: several agents edit the same application at once — one changing dispatch behaviour, one adding a depot rule, one updating tests. Each has its own branch and possibly its own git worktree, and all of them connect to the same development database. So an agent resets fixtures while another is asserting on a write. A test changes a document that a different task expects to stay unchanged. A cleanup script deletes the database while another suite is still running. The post pins the problem in one line: the code is isolated, the state is not.
Branches do not fight because they do not share directories. Data has no such privilege.
1. No migration scripts does not mean no shared state
The post removes a comfortable assumption first. Cosmos DB does not impose the table-schema migration workflow a relational database does, but that does not remove shared-state collisions. Applications still depend on document shapes, partition key choices, indexing policies and predictable test data. Dropping schema migrations solves half the problem; the other half is two tasks treating the same records as their own. The observation generalises to any team running a document store behind a service: what you removed was migration, not concurrency.
# Spawn an emulator that owns its lifetime, its ports and its data.
# The article's helper does exactly this; the shape is worth copying.
import docker, socket, time
client = docker.from_env()
def port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
return p
def start_env(task_id: str):
db_port, ready_port = port(), port()
c = client.containers.run(
"mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest",
detach=True,
name=f"cosmos-{task_id}", # invocation-owned name
ports={"8081/tcp": ("127.0.0.1", db_port),
"8080/tcp": ("127.0.0.1", ready_port)},
labels={"owner": "agent-task", "task": task_id},
environment={"AZURE_COSMOS_EMULATOR_PARTITION_COUNT": "3"},
)
wait_ready(ready_port, timeout=180)
return {"id": c.id, "endpoint": f"https://127.0.0.1:{db_port}"}
# Internal ports stay 8081 and 8080. Only the host side is dynamic,
# bound to loopback, so two tasks never fight over 8081.2. The unit of isolation is the task, not the branch
The proposed pattern fits in a sentence: give every coding task a disposable data environment — separate worktree, separate emulator, known fixtures, independent tests, cleanup it owns. The post borrows the isolation model of a git worktree and is careful to say it borrows only the model: no copy-on-write snapshots, no cloning a production account, no merging database contents. Each environment starts from the same versioned synthetic fixtures, can change independently, and can be discarded whole. The better part is the experimental design. Both environments deliberately reuse identical identities: the same database name, the same dispatchUpdates container, the same current-dispatch document id, the same SEA-01 partition key, the same starting bay BAY-2 — then A writes BAY-7 and B writes BAY-8. With different ids, a non-collision might just be luck. With identical ids, each environment reading back its own value is proof that the boundary really sits at the emulator instance.
# Status codes lie. Read the stored item back through an independent client.
from azure.cosmos.aio import CosmosClient
async def verify(env_a, env_b):
async with CosmosClient(env_a["endpoint"], credential=KEY) as a, \
CosmosClient(env_b["endpoint"], credential=KEY) as b:
ca = a.get_database_client("fleetchargetest-agent-isolation") \
.get_container_client("dispatchUpdates")
cb = b.get_database_client("fleetchargetest-agent-isolation") \
.get_container_client("dispatchUpdates")
# Same database, same container, same document id, same partition key.
doc_a = await ca.read_item("current-dispatch", partition_key="SEA-01")
doc_b = await cb.read_item("current-dispatch", partition_key="SEA-01")
assert doc_a["pickupBay"] == "BAY-7" and doc_a["updateRef"] == "agent-a-dispatch"
assert doc_b["pickupBay"] == "BAY-8" and doc_b["updateRef"] == "agent-b-dispatch"
# A control document that must never move.
for c in (ca, cb):
pdx = await c.read_item("current-dispatch", partition_key="PDX-02")
assert pdx["pickupBay"] == "BAY-99"
assert pdx["updateRef"] == "dispatch-099"
# Deliberately reusing the same identities is the point. Different ids
# would let a leak pass unnoticed; identical ids force the boundary to be
# the emulator instance, which is what you actually want to test.3. Verify stored state, not successful requests
The most transferable discipline in the post is this: a 200 response is not a pass. After both writes complete, an independent SDK client performs a point read on the same item with the same partition key, checking not just the id but the depot, the pickup bay and the update reference. A control document is read too, to confirm it never moved in either environment. Then the post tests the lifecycle boundary: remove environment A, verify it is gone, and read B again with a fresh client, which must still hold its own update. That check is worth stealing because it turns cleanup from "hopefully the script did the right thing" into an assertion. Plenty of data pollution gets in exactly this way: a teardown deletes too much and no test is watching.
Two emulators, both on internal ports 8081 and 8080, with host ports assigned by Docker.
# The runner hands each test subprocess its own configuration.
# It never rewrites a shared .env or mutates process globals.
import os, subprocess, sys
TASKS = {
"dispatch": ["tests/unit/test_dispatch.py", "tests/integration/test_feed.py"],
"fleet-rules": ["tests/unit/test_fleet_rules.py", "tests/integration/test_delivery_wave.py"],
}
def run(task: str):
env = start_env(task) # owned container, dynamic ports
try:
seed_fixtures(env["endpoint"]) # versioned synthetic fixtures
for path in TASKS[task]:
code = subprocess.run(
[sys.executable, "-m", "pytest", path, "-q"],
env={**os.environ,
"COSMOS_ENDPOINT": env["endpoint"],
"COSMOS_KEY": TEST_KEY,
"COSMOS_READY_URL": env["ready_url"]},
).returncode
if code != 0:
raise SystemExit(code)
verify_stored_state(env)
finally:
cleanup_owned(env["id"]) # only what we created
# Same command locally and in CI: python -m scripts.agent_ci --task dispatch
# An agent can therefore pre-flight exactly what CI will run.4. One emulator per task in CI as well
Local isolation is half the job. The post moves the same pattern into GitHub Actions with a new Agent task CI workflow whose matrix holds two task presets: dispatch runs the dispatch unit and feed integration tests, fleet-rules runs the fleet-rule unit and delivery-wave tool integration tests. Each matrix entry receives its own GitHub-hosted Ubuntu runner and starts one emulator it owns, with host ports dynamically assigned and bound to loopback, while the emulator's internal 8081 (database) and 8080 (readiness) stay put. Two details are easy to get wrong. Configuration must be passed explicitly to each subprocess — the sample does not rewrite a shared .env or mutate process-global environment variables, and that rule matters even more once a launcher fans out child processes, because inheriting the parent environment is invisible cross-talk. And fail-fast should be off, or the first red task cancels the rest and you lose the ability to tell a task-specific failure from an environmental one.
# .github/workflows/agentic-ci.yml
# One matrix entry per task preset. Each gets its own runner and emulator.
name: Agent task CI
on:
pull_request:
workflow_dispatch:
jobs:
task:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
task: [dispatch, fleet-rules]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- name: Run isolated task checks
run: uv run --locked --extra test python -m scripts.agent_ci --task ${{ matrix.task }}
- name: Upload environment report
if: always()
uses: actions/upload-artifact@v4
with:
name: agent-task-${{ matrix.task }}
path: artifacts/${{ matrix.task }}/
# fail-fast: false matters more than it looks. With fail-fast on, the
# first red task cancels the rest and you lose the signal about whether
# the failure is task-specific or environmental.5. Failure has to look like failure
The final section reads like it was written by someone who has been on call. When a read-back assertion fails, the process must exit nonzero, clean up the containers it owns, and produce no success report — the post's own rule is that a failed assertion must not look like a successful environment run. In code that means three things: capture diagnostics before cleanup, because a crashed emulator still owes you its logs; assert after cleanup that the container is actually absent; and write the JSON result report only once verification and cleanup have both completed. Each invocation also keeps its own logs and a JSON report under a unique ignored artifacts directory. It sounds fussy until you have a swarm of agents, at which point the dangerous outcome is not a failure — it is a quiet pass. There is a second benefit that the post does not claim but teams discover quickly: once every task seeds from the same versioned fixtures, a flaky test stops being a mystery and starts being a fixture bug, which is a fixable thing.
One runner per matrix entry, one emulator of its own.
# Failure must look like failure: nonzero exit, cleanup, no report.
import json, pathlib, shutil
def finalize(task, env, ok: bool, detail: dict):
art = pathlib.Path("artifacts") / task
art.mkdir(parents=True, exist_ok=True)
try:
if not ok:
# Diagnostics are captured BEFORE cleanup; a crashed emulator
# still owes you its logs.
(art / "diagnostics.log").write_text(collect_diagnostics(env))
(art / "failure.json").write_text(json.dumps(detail, indent=2))
raise SystemExit(2) # never exit 0 on a failed assertion
cleanup_owned(env["id"])
gone = assert_container_absent(env["id"])
(art / "result.json").write_text(json.dumps(
{**detail, "cleanup_verified": gone}, indent=2))
finally:
if art.exists() and not (art / "result.json").exists():
shutil.rmtree(art, ignore_errors=True)
# The rule from the article, restated as code: the JSON result report is
# written only after verification AND cleanup. A failed assertion must not
# look like a successful environment run.📌 Frequently Asked Questions
Which tools does the walkthrough use?
GitHub Copilot acts as the coding agent editing the FleetCharge sample repository in GitHub Actions, with a separate Azure Cosmos DB emulator per task. Ollama is used separately for local application testing and is not used to run or evaluate those coding agents. The sample commands need Python 3.12, uv, and Docker running Linux containers on a supported Windows or Linux x86-64 host.
Why not just create a database per task?
The post says a separate database per task inside one emulator is a useful, lower-overhead design. It chose separate containers so that instance lifetime is independent too: removing A must not stop B. Pick based on whether you need to isolate data only, or instance existence as well.
How are port collisions avoided?
The emulator's internal database and readiness ports stay 8081 and 8080, while Docker assigns different host ports for each instance bound to 127.0.0.1. The command prints the actual endpoints, so two tasks never compete for host port 8081.
Why deliberately reuse the same document ids?
To make the boundary unambiguous. Different ids make a non-collision easy to achieve by accident and hide whether isolation actually works. Reusing the same database, container, item id and partition key, while each environment reads back its own value, proves the isolation point is the emulator instance.
Why insist on a nonzero exit when an assertion fails?
So an orchestrator cannot mistake a failed environment run for a pass. The post's requirement is that a failed read-back exits nonzero, cleans up the owned containers, and produces no success report — otherwise the next agent continues on polluted state.