Agents Are a Concurrency Problem
August 11, 2026 · View on GitHub
Why Lemon is written in Elixir, what the BEAM actually pays for, and what it costs.
I did not pick the BEAM because I like Elixir. I picked it because every agent system I had built before was slowly growing a worse copy of OTP inside it, and I got tired of maintaining the copy.
The problem, in the terms you already know
If you build agents in Python or TypeScript, four things have probably hurt you.
Many long-lived, stateful conversations. You have one OS process, an event loop, and
a dict of session objects keyed by ID. State lives in the dict, concurrency lives in
asyncio, and failure lives nowhere in particular. The moment you need two machines, the
dict becomes Redis and every piece of session state becomes a serialization problem — not
because the state got more complicated, but because the runtime never gave you a unit of
isolation smaller than the whole process.
Partial failure. A tool call raises. A provider closes the stream at token 4,000. What
should die: the tool call, the turn, the conversation, the worker? In a single-process
runtime the blast radius is whatever your try/except happens to cover, and the default
when you get it wrong is that one bad tool takes down a request handler serving 200 other
users.
Retries that duplicate side effects. Your queue redelivers. Your HTTP client retries. The agent sends the same message to the user twice, or books the same meeting twice. The retry lives in a library that has no idea whether the operation it is retrying was observable from outside.
Observability of emergent behavior. Two agents wait on each other. A run hangs for forty minutes. "Which of my three hundred conversations is stuck, and on what?" is a question logs answer badly, because the thing you want to inspect — a live, in-flight unit of work — is not addressable in the first place.
None of these are LLM problems. They are the problems of a system made of many independent, long-lived, failure-prone units of work that talk to unreliable things. That is the system Ericsson built the BEAM for, and the whole argument of this document is that agent runtimes have quietly become that system.
What the runtime gives you, and where it shows up here
A conversation is a process, not a dict entry
LemonRouter.RunProcess is a GenServer that owns exactly one run
(run_process.ex:42), addressed
by {:via, Registry, {LemonRouter.RunRegistry, run_id}}. Abort state, the watchdog timer,
the metadata snapshot, the Bus subscription — all of it is ordinary process state. There is
no serialization, no lock, and no "who owns this session" question, because a process is
the answer to all three. When the run finishes the process exits and the state is gone;
there is no cache eviction policy because there is no cache.
Three properties follow, and they are the actual reason this matters:
- Preemptive scheduling per process. A run that spends ninety seconds parsing a 5MB tool
result does not stall the other 499. There is no
awaitI can forget to write. - A heap per process. A conversation carrying a 200k-token history garbage-collects on its own heap. Nothing stops the world.
- Admission control instead of degradation.
RunSupervisorcaps concurrent runs (run_supervisor.ex:15, default 500) and returns{:error, :max_children}at the limit rather than accepting work it will serve badly.
Supervision is a retry policy you can read
The interesting question is never "restart on crash." It is what to restart, and the BEAM makes you answer it in one visible line.
Run processes are started restart: :temporary
(run_supervisor.ex:54). A
crashed run is deliberately not restarted, because replaying an LLM turn replays its
tool calls, and those wrote files and sent messages. The supervisor still receives the
exit, the Registry entry still disappears, monitors still fire — I get the cleanup without
the duplication. Compare channel adapters, which run :one_for_one under a
DynamicSupervisor (lemon_channels/application.ex:24):
a dropped Telegram connection should come back, because reconnecting is idempotent. The
idempotency argument lives in the supervision strategy, where a reviewer can find it.
One level down, each tool call runs as a supervised task with a monitor
(loop/tool_calls.ex:63,569). If
it crashes, the :DOWN message is converted into a tool result the model can read —
"Tool task crashed: ..." at
tool_calls.ex:1027 — and the
other tools in the same parallel batch keep running. That is a receive block, not a
framework.
Backpressure and at-most-once are process-shaped
The outbound path is a bounded queue with an explicit rejection: 5,000 messages
(outbox.ex:22), and past that,
{:error, :queue_full} plus a telemetry event carrying the depth
(outbox.ex:94). Rate limiting and
deduplication are separate processes, so a wedged delivery cannot take the accounting with
it. Delivery is keyed by an idempotency key, and repeat sends return {:error, :duplicate}
rather than a second message to the user.
The comment at outbox.ex:42-53 is
the part I would point at in an interview. When enqueue times out, the message may still
be in the GenServer's mailbox and may still be delivered — so the caller gets
:enqueue_timeout, named to mean ambiguous, not failed. You can only write that
comment in a runtime where you know exactly where the message is.
Single-flight per session is a unique-key Registry
(lemon_router/application.ex:18):
at most one active run per session key, enforced by the fact that two processes cannot hold
the same registry name. It is not a lock with a TTL and a recovery story. It is a name, and
it is released when the process dies, for any reason, including the node being killed.
What happens to the second message instead of being dropped is the job of
LemonRouter.SessionCoordinator — one process per conversation — which interprets queue
modes (:steer, :steer_backlog, :collect, :interrupt). The decisions themselves live
in session_transitions.ex
as a pure reducer; the process only performs the I/O the reducer asks for.
Provider failure is isolated the same way: a circuit breaker and a concurrency cap per
provider, each a process (ai/circuit_breaker.ex,
ai/call_dispatcher.ex), so an Anthropic outage
returns {:error, :circuit_open} in microseconds instead of consuming every worker that
would otherwise have served OpenAI traffic.
Live systems are inspectable
Because every run and every conversation is a named process, a stuck agent is a thing you
can attach to: :observer, Process.info/2, a remote shell into production, message-queue
depth per conversation. The idle watchdog
(run_process/watchdog.ex:15)
and the bounded, drop-strategy-configurable event stream
(event_stream.ex:64,95) are cheap to
build on top of that, but the inspectability is the primitive.
What it costs
If I only wrote the section above, you should not believe me.
There is no ML ecosystem. LemonAi.Tokens is a
four-characters-per-token heuristic, and its moduledoc says outright that it is not a
tokenizer. That is not laziness; there is no maintained BPE tokenizer for Elixir I would
take a NIF dependency on. Nx and Bumblebee are real and good, and they are not PyTorch. If
your agent needs local inference, embeddings, or reranking, that work happens somewhere
else.
Anything with a native or JS-only SDK becomes an OS process boundary. WASM extensions
run in a sidecar over a port protocol
(wasm/sidecar_session.ex);
browser automation shells out; the TypeScript packages under clients/ exist because the
browser is the browser. Ports are the correct answer — a crashing sidecar cannot corrupt
the VM — but you pay a serialization boundary and a supervision tree for what Python would
have solved with an import.
NIFs are where the guarantees stop. A NIF can crash the whole VM and can block a scheduler thread. Every isolation property above is a property of Erlang code, not of the process boundary in general.
The hiring pool is small. This is a real cost and I will not argue it away. The mitigation I believe in is that the concepts transfer: someone who has reasoned about actors, mailboxes, or Go channels can read this code in an afternoon.
Distribution is not free, and "distributed by default" is oversold. The Bus is a thin
wrapper over Phoenix.PubSub when that optional dependency is present and a local-only
Registry when it is not, and its moduledoc
(bus.ex) says exactly that, because a fallback
that silently stops crossing node boundaries is worse than no fallback.
Not everything wants to be a process. LemonSim's simulation kernel is a pure reducer
(kernel/runner.ex) specifically so
that a run is reproducible from a seed and a scorecard is a function of final state. That
is not just asserted: a test runs the same seed twice and checks the final world state and
every artifact hash are byte-identical
(determinism_test.exs).
Processes wrap it — Task.async_stream for suite concurrency
(bench/suite.ex:237, one always-on
GenServer per arena domain in arena.ex)
— but they stay out of the state machine. Determinism and concurrency want opposite things,
and nothing in the runtime draws that line for you.
What this repo is evidence of
The claims above are checkable, which is the point of publishing them:
- Boundaries are enforced, not asserted.
@grandfatheredinarchitecture_rules_check.ex:584is an empty list. It held 29 authorized cross-boundary references when the check was added, and the policy was shrink-only: entries could be retired, never added. Now that it is empty, any cross-boundary reference at all fails CI. A companion test also rejects entries whose underlying reference no longer exists, so the list cannot rot into decoration. - Extension points ship with compliance suites.
lemon_platform_testprovidesExUnitcase templates for the channel, engine, store-backend, and memory-provider behaviours, so a third party can run the platform's own contract tests against their implementation. The suites were validated by running a deliberately broken backend through them and confirming they fail. - The plan records its own reversals. The Decision Log in
platform-split.mdcontains D2 and D11 — two decisions overturned after the code disagreed with them — with the evidence that overturned each. - The concurrency story has a demo. LemonSim runs deterministic, seeded, replay-verifiable multi-model games; the always-on arenas keep them running and score them continuously.
Deeper invariants and the supervision-tree diagrams are in
beam_agents.md, including its list of known limitations — event fan-out
is still fire-and-forget send/2, and tool abort is best-effort. Those are honest gaps in
a runtime that makes it unusually easy to see that they are gaps.