Your Agent Fleet's First Bottleneck Is Your Laptop's Shared State

2026-07-22 - 6 min read
Daniel Young
Daniel Young
Founder, DRYCodeWorks

Machine-global identity doesn't survive concurrency. When ten agent sessions share one active VPN identity, you don't get an error. You get silent black-holes that look like network blips. Here's how to give each its own.

tailctl — per-identity Tailscale networking for parallel sessions on a single Mac

I lost most of a morning to what looked like a bad network connection.

I had several autonomous coding sessions running at once, spread across different clients and regions, each one reaching into a private network to do its work. One by one they started stalling. A session would run fine for a while, then hang on a request that should have taken milliseconds. There was no error and no stack trace, just a timeout, the kind you get when the coffee-shop Wi-Fi drops.

Except my Wi-Fi was fine. The sessions weren't competing for bandwidth. They were competing for something I hadn't thought of as shared at all: my machine's network identity.

It wasn't the network, it was one global variable

Each of those private networks was a separate Tailscale tailnet. To reach a client's infrastructure, my machine has to be on that client's tailnet, authenticated as a device and routing through the right exit node. The standard way to do that is to switch the active account, and the active account is the whole problem: it's one value, shared by the entire machine.

The moment a second session needed a different tailnet, it switched the global identity out from under the first. Now the first session's packets were leaving for a network that had no idea how to route them.

Silent black-hole: when the active network identity is wrong, packets aren't rejected with a clean error. They leave for a network that can't route them and never come back. To the process waiting on the socket, it looks exactly like a flaky connection.

That's the part that cost me the morning. A permission error I'd have fixed in five minutes. A silent failure that imitates a network blip sends you chasing the wrong things, like retries and timeouts and DNS, while the real cause is two processes fighting over one global switch.

Concurrency exposes every global

The active tailnet is just global mutable state, and I was writing to it from several concurrent processes with no lock between them. That's a race condition, and it always had been. I'd just never run enough at once to hit it.

Every piece of global mutable state on your machine is a race condition waiting for the day two things need it at once. Running in parallel doesn't create that bug, it just finally exposes it.

Network identity isn't the only global on your laptop, either. The same pattern shows up anywhere you have a "currently active" something:

  • Active VPN / tailnet identity: one device registration, one active account, machine-wide.
  • Active cloud profile: the "default" credentials your CLIs pick up, so two sessions in two accounts clobber each other.
  • Global git / SSH identity: one signing key, one agent socket, one user.email.
  • "Current context" CLIs: the selected kube context, the active database connection, the chosen region.

A single developer doing one thing at a time never notices. A fleet of autonomous agents, which is where a lot of us are heading, trips over all of them. When you hit a ceiling on how many you can run at once, the reason usually isn't CPU or memory. It's that the tenth session wants a different value for a global the other nine are already using.

Why the obvious fixes don't hold

The instinct is to serialize: let one session touch the shared identity at a time. Wrap the switch in a lock, queue the work, and take turns.

But serializing defeats the entire reason you're running agents in parallel. If session three has to wait for sessions one and two to finish their network calls before it can switch the tailnet and set up its own, you've rebuilt a single-threaded workflow with extra steps. Throughput collapses back to one at a time.

Global-switch approaches also tend to flap: constant switch-and-switch-back cycles that thrash the daemon, and on desktop tooling they can even yank the identity your browser is using. The result is worse than slow. It's unstable, and instability is harder to chase down than plain latency.

So the fix isn't a smarter way to switch the global. It's to not have one global to switch.

Identity as a per-process resource

Instead of one machine-wide identity that every process mutates, give each process its own identity that nobody else can see.

Machine-global identityactive tailnet(one global)agent 1✓ connectedagent 2✕ black-holedagent 3✕ black-holedSwitching serializes — the losers fail silently.Per-identity tailctlagent 1own tailscaledagent 2own tailscaledagent 3own tailscaledEvery session its own node — all run in parallel.

That's the idea behind tailctl, a small tool I built to do exactly this on macOS. Each profile runs its own headless userspace tailscaled: a private Tailscale node with its own SOCKS5/HTTP proxy and its own localhost port-forwards. Many of them run at once, and none of them touches the GUI Tailscale app, so your browser's tailnet stays put.

Traffic is opted in per command, not globally. A session routes exactly the calls it means to:

bash
# Tie the identity to this session so it survives across calls
export TAILCTL_OWNER_PID=$$

# Run one command routed through a specific identity...
tailctl run acme-dev -- curl https://service.internal

# ...or a native-TCP client via a stable localhost forward
tailctl run acme-dev -- clickhouse-client --host 127.0.0.1 --port "$CLICKHOUSE_ADDR"

Because each identity is its own process with its own proxy, there's no shared value to race on. Session one and session ten can sit on completely different tailnets at the same time, and neither can black-hole the other. The parallelism that used to be the bug is just how the tool runs now.

This goes well past Tailscale. Whenever some shared "active X" breaks under concurrency, the durable fix tends to be the same: make X a per-process resource you pass around explicitly, instead of a global you mutate in place.

Where this goes

Running a fleet of agents turns your laptop into a small multi-tenant host, whether you planned it that way or not. Each agent is a tenant, and multi-tenancy only works when the tenants are isolated, so one can't quietly corrupt another's state. Network identity was just the first global I tripped over, mostly because its failure mode hides so well.

The rest of the list has the same shape: per-identity cloud credentials, per-identity git config, per-session context. Give each agent its own copy of whatever it would otherwise fight over, and "how many can I run at once" stops being a question about shared state and goes back to being a question about hardware.

tailctl is open source (DRYCodeWorks/tailctl) and on PyPI, so a pip install tailctl gets you going if you're hitting the networking slice of this. And if you're building out autonomous, multi-account developer infrastructure and want a second set of eyes on where the shared-state landmines are, that's the kind of thing I do.