The Conversation Is a Lane
A conversation is not a request and a reply — it is an ordered event bus, one lane per conversation, and a turn is a consumer that briefly owns it. Everything lands there in order: a prompt, a widget save, a webhook, a mid-flight "stop." The hard part is not delivery — it is ownership when turns overlap, and guaranteeing a stale turn can never commit. This piece walks the path from client action to folded block through the separations that make it work: wake vs event body, handler vs consumer, and bus event vs timeline block.
A conversation looks like a request and a reply. Underneath, it is not. It is an ordered event bus — one lane per conversation — and a turn is just a consumer that briefly owns that lane. Everything a user or a surface does becomes an ordered event on it: a typed prompt, a file dropped on a widget, a webhook from a third-party app, a mid-flight "stop." They all land in the same stream, in the order the lane assigned, and a running agent turn folds them in.
That reframe is the whole design. Once a conversation is a lane instead of a call, the hard engineering is no longer delivery — Redis delivers. It is ownership: when a new event arrives while a turn is already running, or a worker dies mid-turn, or two turns race for the same conversation — which turn owns the lane, and how do we guarantee a stale turn can never commit its answer? The event bus is built almost entirely around answering that.
This article walks the path from a client action to a folded ReAct block, and dwells on the three things that make it more than a message queue: the split between a wake and the event body; the handler/consumer ownership protocol with its supersession fences; and the gate that keeps a bus event distinct from a timeline block — including the live control events (steer, followup) that ride the same lane.
One lane per conversation
Every accepted event belongs to exactly one lane, named by a composite identity — the id_card:
tenant + project + user_id + conversation_id + agent_id
When the client names no agent, the platform fills in default.react.agent. That last coordinate
matters: two agents in the same conversation are two lanes. The lane is the unit of ordering and the unit of
ownership; nothing below it interleaves.
Identity on the lane is deliberately layered, so ordering, occurrence, and meaning never get confused:
id_card which lane + timeline scope (tenant/project/user/conv/agent)
event_source_id the semantic source + policy key
event_id one accepted occurrence (assigned by the lane)
sequence monotonic order in the lane (assigned by Redis)
For one id_card, the Redis lane sequence is the event order — full
stop. The processor's ready queue is a wake-up channel and must never be treated as the ordering source. This
separation is the first load-bearing idea, and the next section is its consequence.
The wake is not the event
Ingress does one subtle thing that shapes everything downstream: it splits "there is work" from
"the work." When a client submits an external_events[] batch, ingress validates
auth/session, resolves the lane identity, normalizes event ids and timestamps, then does two independent
writes:
L; a small
pointer goes to the queue Q — and only when reactive work exists.The body goes to the lane L. A small wake goes to the processor queue
Q — and only if the batch actually contains reactive work. That wake is an
ExternalEventLaneWakeup: service/routing/user context plus the lane coordinates to find the
occurrence — but it intentionally does not contain the request. The prompt text, the attachment,
the steer text, the domain payload — all of that is recovered later from the lane event's stored
task_payload.
There is one event protocol, ExternalEventPayload; the queue merely carries a pointer back to it.
Why go to this trouble? Because a wake can be stale by the time it is dequeued — a later turn may
already have consumed past it — and because promotion of an unconsumed event should re-point at the current lane
state, not replay a frozen request body. Keeping the body in L and only a pointer in Q is
what lets the processor make an up-to-date decision at dequeue time instead of acting on a snapshot of
intent.
Two roles: handler and consumer
Ownership is split into two roles that are easy to conflate and must be kept apart:
Handler the TURN that currently owns the lane — logical ownership, not a process
Consumer the loop that reads lane events and folds them — the ContextBrowser event reader
The handler is a claim in a shared state table T — "turn-A owns this lane."
The consumer is the live reader loop inside the running turn. The state table holds both, as flat
fields:
T.handler_turn_id
T.handler_status open | closed
T.handler_status_at
T.consumer_status active | scheduled | none
T.consumer_status_at
T.last_processed_event_timestamp
T.last_processed_reactive_event_timestamp
Here is the trap the design steps around: handler_status_at is not liveness. It only
records when handler state was last written. A handler can read open forever after its worker
has died. The real liveness signal is a fresh consumer_status_at — a heartbeat the active
reader refreshes only while it still matches the current handler. Liveness lives with the consumer,
ownership lives with the handler, and the whole recovery protocol turns on not mistaking one for the other.
- Fresh active
consumer_status_at→ do not steal the handler; a live owner is folding. - Stale or missing
consumer_status_at→ reclaim the lane for a new turn. - Fresh scheduled
consumer_status_at→ don't start a duplicate wake — but this is not owner liveness at handler open.
Four ways to answer a wake
When the processor dequeues a wake, it does not immediately start a turn. It consults
T and picks exactly one of four outcomes. This little decision is where duplicate turns, thundering
wakes, and races get filtered out:
- Ignore — the wake points at reactive work whose timestamp is already past the processed cursor. The wake itself is obsolete; the lane already proves the work was handled.
- Defer to active — a live consumer heartbeat is fresh; starting another turn
would create a competing owner. The event stays in
L; the live owner will read it. - Defer duplicate scheduled — proc just reserved a start for this lane. During app load / entrypoint handoff, a second wake must not spawn a second starter.
- Schedule — nothing fresh covers the lane, so proc marks
T.consumer = scheduledand begins loading the app.
The distinction in one line: "ignore" means the wake is obsolete; "defer" means the wake is still valid but someone already has it covered.
The fence chain
Between "proc decided to run a turn" and "the turn committed an answer" lies a chain of runtime fences. Each is a point where, if the implementation swapped Redis for Kafka or moved the app to a remote runner, the same guarantee would still have to hold. Read top to bottom, they are the life of a turn:
The bundle-load fence is the one people forget. Proc has decided a turn should run, but the lane
is not consumed yet — the app instance still has to resolve and its @on_reactive_event
entrypoint has to start the runtime. For a non-singleton app this can take observable time. That is precisely why
the state table first records T.consumer = scheduled: it is a short reservation that stops a
competing turn from starting during the load window — while explicitly not claiming the old
handler is alive. (@on_reactive_event is the real decorator; the manifest metadata field is still
named on_message/OnMessageSpec.)
Superseded turns never commit
The fences exist for one worst case: a turn goes quiet long enough that its consumer heartbeat goes stale, a second turn reclaims the lane, and then the first turn wakes back up. Without a rule, both would try to answer. The rule is: a lane event is folded, and an answer is committed, only for the turn that still owns the lane.
ExternalEventLaneTurnSuperseded and rolls back. Only the current owner ever commits.ContextBrowser rechecks ownership at every moment where stale work would matter — handler open,
consumer ack, each event fold via accept_events_for_open_handler(...), and once more at
finish_turn before the answer is emitted. On mismatch it raises
ExternalEventLaneTurnSuperseded, which is deliberately routed through the ordinary
turn-exception path — the stale turn is discarded like any wrong turn, its partial in-memory work abandoned, while
the newer owner continues from the last committed index. Late execution becomes idempotent at the turn boundary: a
delayed turn can wake up, but it cannot make itself the conversation head.
The bus event is not the timeline block
Here the design makes its second sharp cut. Arriving on the bus and appearing on the ReAct timeline are two separate decisions. The bus preserves order and hands the app a callback; a block-production policy independently decides whether the event also becomes model-visible timeline material.
This is what lets a widget push a telemetry-style save boundary onto the lane: the app callback stores or
forwards it, the source binds react.block_production.no_timeline, and the occurrence advances
the lane cursor without ever creating a durable ReAct block. The event was real, ordered, and acted upon —
and invisible to the model by design. Conversely, when a policy does share file material, it can share
only refs (fi: paths, hosted artifacts) and let ReAct pull the bytes on demand with
react.read / react.pull — the body never has to be inlined into the event.
Kind is transport, type is meaning
One more separation, and it is the one that most recently bit us in production. Every lane event carries two names: an operational lane kind and a semantic event type. The kind is a scheduling label. The type is the meaning.
lane kind message · followup · steer · external_event <- operational / scheduling
event type event.user.prompt · event.user.followup · <- semantic (what it MEANS)
event.user.steer · event.external · event.canvas · event.snapshot
The catch: in-flight events submitted through the plural external_events[] batch all arrive with
lane kind = external_event. The real type (event.user.steer,
event.user.followup) lives only nested inside payload.event.type. A live consumer that
branches on the kind would see one undifferentiated external_event for everything — and a live
"stop" would be silently dropped.
So live consumers must recover the semantic type before deciding anything. The ReAct runtime
does exactly this — on_external_event calls
live_events.recover_semantic_event_type(...) to dig the real type out of the nested payload before it
decides steer-interrupt vs iteration-credit. Key off the transport label and you drop the user's intent; key off
the recovered type and the bus stays honest.
Steer and followup: live control on the bus
Because control rides the same lane as content, the two live control events are worth their own picture. Both target an already-running turn; each does the opposite thing.
event.user.followup, continuation —
mints iteration credit, and the turn keeps working, fresh.event.user.steer, continuation —
cancels the in-flight generation or tool, and the turn enters a bounded finalize.A followup is new work: it mints reactive-iteration credit and the turn keeps going. A
steer is control: an empty steer with explicit is a pure stop; a steer
carrying text is a redirect. On a stop, the runtime cancels the in-flight phase (the docker tool container is
killed if one was running), preserves the partial output, and enters a bounded finalize — a couple
of capped rounds that produce a short "stopped at your request" wrap-up, then end.
The subtlety the bus forces on us: a steer only stops the current work — a followup is a valid way to continue the turn after a steer. So a queued followup must supersede the steer's bounded finalize, regardless of whether it arrived before or after the steer, and let its generation run at full budget — while a bare steer with nothing queued still gets its short wrap-up. And a steer is a live control event only: it is never promoted after the active turn expires, because a "stop" for a turn that already ended is meaningless. Followups and generic events can be promoted; control cannot.
Why a bus, and not a queue
Every separation in this design pulls apart two things a naive message queue would fuse. Laid side by side:
| The bus keeps apart | …so that | A plain queue would fuse them into |
|---|---|---|
| Wake vs event body | the processor decides on current lane state, not a frozen request | replaying a stale request body |
| Handler vs consumer | a dead worker's open handler can be safely reclaimed |
ownership that never recovers |
sequence vs queue |
order is authoritative even as wakes race | the queue accidentally defining order |
| Bus event vs timeline block | a widget save can be bus-only; a prompt is model-visible | every event polluting the model context |
| Lane kind vs event type | a live "stop" is recovered, never dropped | control lost behind a transport label |
| Fold vs commit | a superseded turn is discarded before it can answer | two turns racing to commit |
Read down the middle column: each row is a failure the naive design ships and the bus refuses. That is the through-line — the event bus is not a delivery mechanism with features bolted on; it is a set of deliberate separations, each earning its keep against a specific way conversations break when turns overlap.
Where this is going
The current implementation runs the app and the ReAct runtime inside chat-proc. That is an
implementation detail, not a semantic requirement — a remote app runner would inherit the same fences:
accepted-event storage, wake scheduling, lane ownership, consumer heartbeat, ordered lane read, and superseded-turn
rollback before persistence. The fences are the contract; where the code runs is not.
Two gaps are named honestly in the status table. Non-reactive idle events are retained today
only in the Redis lane, with a bounded window (CHAT_EXTERNAL_EVENTS_STREAM_MAX_ENTRIES, default 1024;
…RETENTION_SECONDS, default 7 days) — operational state, not durable business history.
Durable idle event-history materialization is pending, and the goal is to stop pretending Redis
retention is permanent conversation history. And the conversation-native scheduler — folding
out-of-turn non-reactive events into a durable timeline without waiting for the next turn — is still design-only.
The reframe holds through all of it: a conversation is a lane; a turn is a consumer that briefly owns it; and the discipline that keeps overlapping turns correct — ownership, liveness, supersession — is the same whether the lane is Redis or Kafka, and whether the consumer runs in-proc or across the network.
Documentation on GitHub
The live docs behind this entry: