Parallel Agent Branches Cannot Be Merged Blindly
Parallel agent branches need different merge rules: reducers for facts, event logs for auditability, and explicit conflicts for decisions.

Parallel branches save an agent time while each one works on its own part of the task. The problem begins at the merge point. If two branches change the same object at the same time, a simple merge turns response delivery order into business logic. That is a bad trade: the network, queue, and model latency start deciding what your code should decide.
A branching agent's state has three different modes of change. Some changes can be combined, others can be accepted only when the version matches, and a third group requires a separate decision. Reducers, event logs, and explicit conflicts are not interchangeable patterns. They serve these three different modes. A team that mixes them in one merge_state() almost always gets silent data loss, repeated external actions, or incidents that cannot be reconstructed.
One object is not one dictionary
You cannot treat a state object as an ordinary dictionary just because it serializes to JSON. Its fields have different semantics, and one general merge algorithm cannot preserve all of them.
Imagine a refund request. The agent starts four branches in parallel: policy checking, payment lookup, risk assessment, and response preparation for an operator. All of them read revision 41 of the same request. A few seconds later they return updates:
- the policy check adds a rule it found;
- the payment lookup adds a transaction ID;
- the risk branch sets
risk_level = high; - the response branch suggests
decision = approve.
The first two changes are independent. They can be combined without losing meaning. The last two cannot automatically be treated alike. A risk level may rule out approval, while the response branch's decision is often based on an incomplete picture. If a reducer simply joins the fields, the object gets a contradictory state. If the algorithm picks the latest update, the request depends on which branch finished later.
It is more useful to classify fields by the nature of the operation than by the technical type string, array, or object:
- accumulating facts: references, documents found, diagnostic messages;
- replaceable values: a delivery address, the selected model, the current task owner;
- decisions with invariants: a limit, payment status, permission to act, charge amount;
- side effects: an email sent, a transfer created, an external API called.
An array is not always accumulative, and a string is not always replaceable. For example, status looks like a string, but the transition closed -> approved may be forbidden. approvers looks like an array, but simply joining it can add the same approver twice. A data type does not tell you whether operations are compatible.
LangGraph documentation describes the mechanics plainly: a reducer receives the accumulated value on the left and a new update on the right, then returns the next state value. If no reducer is defined, the update overwrites the field. This is a convenient execution model for a graph, but it does not add domain rules for you.
A reducer is safe only for compatible operations
Use a reducer where the order of updates does not change the valid result, or where you have deliberately defined an ordering rule. For agent branches, checking this with a couple of examples is not enough. You need to test the operation's properties.
A good reducer for parallel state usually aims for three properties: associativity, commutativity, and idempotency. Associativity means that grouping updates does not change the result. Commutativity means that the order of independent branches does not matter. Idempotency means that delivering an update twice does not create a second effect.
Adding numbers is associative and commutative, but not idempotent. That is why a token or attempt counter should not blindly use old + delta if a worker can redeliver a result after a timeout. A set of identifiers is usually safer: adding an existing item does not change the set.
Here is a reducer for accumulating evidence, where every record must have a stable id:
from typing import Iterable
def merge_evidence(left: list[dict], right: Iterable[dict]) -> list[dict]:
by_id = {item['id']: item for item in left}
for item in right:
existing = by_id.get(item['id'])
if existing is None:
by_id[item['id']] = item
elif existing != item:
raise ValueError(f"evidence id collision: {item['id']}")
return [by_id[item_id] for item_id in sorted(by_id)]
This code does something important that is often skipped. It does not try to reconcile two different records with the same identifier. The same id with different content means a protocol error, identifier reuse, or incomplete branch determinism. Silently taking one record would be data loss disguised as a merge.
Here is an example of state where reducers are appropriate for only some fields:
state = {
'case_id': 'refund-918',
'revision': 41,
'evidence': [],
'warnings': [],
'payment_id': None,
'risk_level': None,
'decision': None,
'effects': []
}
For evidence and warnings, you can define merging by identifier. For payment_id, it is better to allow a write only once or require the new value to match the old one. For risk_level, the rule depends on the scale: if levels are genuinely ordered, max can be a valid reducer. But it is dangerous when levels come from different policies or models and are not defined in the same way. There is usually no automatic reducer for decision.
A common mistake is reducing everything to “last write wins.” It looks practical because the code takes one line. But it distorts the reason for the change: the branch that finished last did not necessarily see more data, have higher priority, or have the authority to decide. last write wins works for some user preferences and caches. For state that drives an agent's action, it should be an explicit exception with an explicit name, not the default setting.
CRDTs take the idea of automatic merging further. These structures are designed for optimistic replication and guarantee that replicas converge under their merge rules. They are useful for sets, counters, and collaboratively edited data. But a CRDT does not understand whether approving a refund and closing a financial dispute at the same time is allowed. That question belongs to domain rules, not data-structure mathematics.
Do not confuse a patch with the intent behind a change
A patch says what outcome a branch saw as desirable. An event says what action the branch proposes and why. When a conflict occurs, that difference determines whether you can explain and fix the result.
Compare these two records:
{
"decision": "approve"
}
and
{
"event_id": "01JQ2D7W0XK8P7FJ3T9N",
"case_id": "refund-918",
"expected_revision": 41,
"type": "refund_approval_proposed",
"actor": "policy_branch",
"reason_codes": ["within_window", "payment_found"],
"evidence_ids": ["policy-77", "payment-19"]
}
The first version erases the question “why.” The second stores the author, revision, intent, and evidence. When the risk branch proposes a block, the system can compare two proposals instead of simply colliding two string values.
An event log is not needed because “event-driven architecture is more modern than CRUD.” It is needed when the history of changes is itself a requirement: auditing, restoring state, investigating a wrong decision, replaying after a rule is fixed, or maintaining multiple views of one object. Martin Fowler describes event sourcing as storing every state change as a sequence of events from which a past state can be reconstructed. Microsoft separately warns that it is a complex pattern with costs for migrations, schemas, queries, and concurrent writes.
For agent branches, an event log is especially useful in two cases. First, the object moves through a regulated process, such as credit checks, medical routing, or procurement approval. Second, a branch proposes an action and a separate component validates it against current state. In both cases, the log stores not only “what it became,” but also “what was proposed.”
A minimal record contract for this kind of log looks like this:
{
"event_id": "evt_8f6b0c1d",
"stream_id": "refund-918",
"expected_version": 41,
"run_id": "run_2026_04_17_031",
"branch_id": "risk_review",
"type": "risk_assessed",
"payload": {
"level": "high",
"reason_codes": ["merchant_mismatch"]
},
"causation_id": "cmd_2a16e9",
"idempotency_key": "run_2026_04_17_031:risk_review:1"
}
expected_version protects the stream from being written over after the object has changed. causation_id answers which command caused the event. idempotency_key protects against redelivery. run_id and branch_id distinguish two parallel runs from a retry of the same branch.
An event log does not eliminate reducers. It moves the merge point. Branches can write independent events to an append-only stream, and a projector can build the current view: the evidence set, the current risk assessment, and pending conflicts. The state snapshot becomes a derived artifact rather than the only place where the truth exists.
A conflict should be a separate result
When two branches propose incompatible changes, the system should return the conflict as data instead of hiding it in logs or choosing a winner by time.
There is a temptation to call every simultaneous update a conflict. That is unnecessary dramatization. Two branches may add different references, fill different fields, or return the same decision with different evidence. A conflict begins when the system cannot accept both changes while preserving the object's invariants.
For refund requests, invariants might include:
- confirmed high risk prevents automatic approval;
- the refund amount cannot exceed the confirmed payment amount;
- after money has actually been sent, the decision cannot be replaced without a compensating process;
- the same payment cannot be refunded twice.
Instead of merge(), it is useful to have a classification function. It receives the current state and a branch proposal, then returns one of three outcomes: accepted, rejected, or conflict.
def classify_proposal(current: dict, proposal: dict) -> dict:
if proposal['expected_revision'] != current['revision']:
return {
'status': 'conflict',
'reason': 'stale_revision',
'current_revision': current['revision']
}
if proposal['type'] == 'refund_approval_proposed':
if current.get('risk_level') == 'high':
return {
'status': 'conflict',
'reason': 'approval_conflicts_with_high_risk',
'required_inputs': ['risk_assessment', 'human_override_or_rejection']
}
return {'status': 'accepted'}
return {'status': 'rejected', 'reason': 'unknown_proposal_type'}
The word conflict here does not mean the system has failed. It means automation has reached the boundary of its authority. The next step might be a new assessment against a fresh snapshot, a higher-priority branch, a deterministic domain rule, or handoff to an operator. But the choice itself must be visible in the protocol.
An explicit conflict also helps improve models. If a branch repeatedly proposes approval when risk is high, you will see a specific class of errors and can fix the prompt, tools, or input set. When the code simply overwrites a field, that pattern disappears among “strange model responses.”
Do not give the model unrestricted authority to resolve conflicts. It can suggest a clear explanation, collect missing evidence, or classify the type of dispute. It should not independently override a monetary limit, access rule, or external effect that has already happened. The authority for such an action must come from domain policy enforced by ordinary code.
A version protects an object but does not replace a rule
An optimistic version check answers a narrow question: did the object change after the branch read it? It does not answer whether the change is allowed even when the version matches.
Suppose branches A and B read revision 41. A successfully adds an event and the stream becomes revision 42. B tries to add its event with expected_version = 41. The store rejects the write. That is correct: B made its decision from an old snapshot.
You cannot mechanically retry B. First, B must read revision 42, check whether the assumptions behind its decision have changed, and create a new proposal. If A added a harmless comment, B may propose the same action again. If A changed a limit, status, or risk level, B must recalculate its conclusion.
Microsoft documentation discusses this scenario using simultaneous work on one stream: optimistic concurrency control rejects an append if the stream changed after it was read, and the handler must reread the state, check the rules again, and only then retry the operation. That is better than the blind retry often found in agent orchestrators.
A version also does not catch conflicts between multiple objects. For example, one branch reserves a customer's limit while another creates a different order that consumes the same limit. Each stream may have a valid local version, while the combined amount violates a shared rule. These cases require an aggregate with a shared consistency boundary, a transaction, a reservation, or a compensation process. A cross-object invariant cannot be solved with a careful reducer for one JSON document.
Find the failure before it reaches production
A typical failure looks harmless. The team creates an application object, starts four branches, and lets each return a partial dictionary. The orchestrator applies updates as they become ready. Tests pass because the stubs respond in a fixed order.
In production, the policy_check branch finishes first and returns:
{
"status": "approved",
"notes": ["policy permits automatic approval"]
}
A second later, fraud_check returns:
{
"status": "manual_review",
"notes": ["device fingerprint mismatch"]
}
The code uses an ordinary dictionary update. Depending on arrival order, the final status becomes approved or manual_review. The notes list is also replaced unless the developer wrote a separate reducer. The execution log contains both responses, but the final object contains one. Then the email branch reads approved and performs an action that an operator cannot easily undo.
The fix is not to apply max to string statuses or add another delay before sending the email. The contract must change.
- Checking branches publish facts and proposals instead of writing the final
status. - One domain handler receives all relevant facts and applies the transition rules.
- When proposals are incompatible, the handler creates a conflict object with reasons and evidence.
- The external-action branch starts only after a separate
approval_confirmedevent.
After that, the first two branches can run in parallel as much as needed. They no longer compete for the right to write the status. They provide inputs to a decision owned by one component.
That owner does not have to be a separate microservice. It can be a function in the same workflow. What matters is that only it can change fields carrying invariants. Separating authority reduces the number of places where an accidental patch can trigger an irreversible action.
A mixed design is usually better than pure ideology
Most agent applications do not need full event sourcing for every step, nor do they need one enormous reducer. A practical design uses all three approaches within their proper boundaries.
Keep short-lived technical data in ordinary run state: intermediate excerpts, search results, tool traces, and temporary hints for the router. These data can be deleted under a retention policy, and losing an old snapshot does not change the business history.
Use reducers to combine independent accumulations: document references, unique warnings, parallel search results, and metrics with no external effect. For every reducer, describe what happens with duplicates, mismatched records carrying the same identifier, and changes in delivery order.
Write events to the log when they explain a decision or have consequences: a proposed limit, a confirmed check, an assignee change, approval, cancellation, or payment request. An event log is especially valuable when an auditor must reconstruct the chain without reading raw model logs.
Use an explicit conflict for operations where two truths cannot coexist. Do not send such conflicts to “LLM errors.” They are normal domain outcomes with their own status, SLA, and owner.
For models from different providers, keep the technical call route separate from the domain event. The model response, model identifier, prompt version, and tool metadata are useful for quality analysis. But the refund_approval_proposed event should not depend on the model name. Otherwise, changing providers will pollute the domain history with execution details.
AI Router can help preserve an existing OpenAI-compatible client while routing requests to different models, but a single endpoint does not replace a state merge contract. Changing base_url must not change the meaning of your events, revisions, or conflicts.
Test the merge contract as business logic
A test that checks only the expected result in one order says almost nothing about a parallel workflow. You need tests for permutations, duplicates, and stale writes.
For a reducer, create a set of updates and run all small permutations. The result should match wherever you claim that order is independent. Then apply one update twice. If the reducer is not idempotent, the test should record that fact, and the contract must explain why duplicates are impossible or how they are filtered.
For an event stream, test the scenario “read, lose the race, read again.” Send a command against revision 41, add a concurrent event, confirm that the append is rejected, and then confirm that recomputation uses revision 42. Do not replace this with a successful-retry test: the key point is that the old intent must not pass without review.
For conflicts, write a decision table. It is simple, but it prevents arguments between developers after release:
| Current state | Branch proposal | Outcome |
|---|---|---|
risk_level = high | approve | conflict |
risk_level = low | approve | accepted |
decision = paid | cancel | rejected or compensation |
| revision changed | any decision | conflict: stale_revision |
Finally, test external effects separately from the decision. An approval_confirmed handler may receive the same record twice after a queue failure. It must determine whether it has already sent the email or created the transfer by using the idempotency_key. “We do not expect duplicates” is not protection. Duplicates appear precisely when the system recovers from a partial failure.
Do not hide a conflict in an error field
The error field is appropriate for transport errors, invalid JSON, and an unavailable tool. A state conflict has a different meaning: the system received two results that are valid in form but cannot be accepted together.
If you put a conflict in an error string, monitoring treats it as a technical failure, the team starts retrying the task endlessly, and the operator cannot see what is missing for a decision. Create a structure that can be shown to a person and processed programmatically:
{
"conflict_id": "conf_41d9",
"stream_id": "refund-918",
"status": "open",
"kind": "decision_vs_risk",
"current_revision": 42,
"proposals": [
"evt_policy_110",
"evt_risk_221"
],
"required_action": "human_review",
"resolution": null
}
After resolution, do not rewrite history as though the conflict never happened. Add a resolution event: who made the decision, under which rule, which proposals were rejected, and whether a compensating action must be started. In sensitive processes, this trail is often more important than the final status value.
Parallelism pays off when branches collect independent facts at the same time. It becomes dangerous when every branch is allowed to declare the object's final state. Keep reducers for what truly combines. Record intent where history matters. And when the rules do not allow both updates, preserve the conflict and make the system resolve it honestly.
Frequently asked questions
When is a reducer enough for agent state?
A reducer works when the operation has a clear combination rule, such as adding unique references, combining diagnostics, or accumulating independent facts. If branches change a price, limit, approval status, or any field with business meaning, the reducer should not silently choose a winner.
Why should you not use last-write-wins for agent branches?
Because branches often see the same snapshot before they start working. A later response is not more correct simply because it arrived later. Without storing the revision and the intent behind the change, the system loses the context of the conflict.
Does every agent workflow need event sourcing?
No. An event log makes sense when you need an audit trail, state reconstruction, decision analysis, or strict object versioning. For short-lived parallel processing of independent fields, it often adds more code and latency than value.
What counts as a real conflict in an agent system?
A conflict occurs when two changes cannot be accepted together without violating a domain rule or making an arbitrary choice. Adding two different tags is usually compatible, while two decisions to approve and reject the same request require an explicit rule or a person.
Which fields should an change record contain?
Include the object identifier, base revision, run identifier, branch, operation type, payload, and creation time. For actions with external effects, add an idempotency key and a reference to supporting data.
Can a branch be retried automatically after a conflict?
A retry is useful only after reading the current state again and checking the invariants again. Do not simply send the old patch again: it was created for another revision and may preserve a decision that is no longer valid.
How do you make branch merging idempotent?
Yes, if every record has a stable identifier and the handler produces the same result when it receives that record again. Idempotency is especially important for payments, emails, ticket creation, and any operation where duplicates are expensive to fix.
Where should you start when introducing a safe merge?
Start by dividing fields into accumulative, replaceable, and decision-dependent types. Then assign reducers only to accumulative fields, add version checks for replaceable fields, and send disputed decisions to a separate conflict handler.
Can CRDTs replace explicit conflict resolution?
CRDTs are useful for data where replicas can converge automatically under predefined rules, such as sets and some counters. They do not answer whether a refund can be approved while a payment dispute is closed. That is a business rule, not a data-structure problem.
Can an LLM API gateway resolve state conflicts for me?
AI Router can preserve your existing SDKs and prompts by changing only base_url to an OpenAI-compatible endpoint, but state rules must be designed in the application itself. A model gateway cannot decide whether two business actions are compatible unless that is expressed in the state contract.