Feature request: let a add_conditional_edges branch write into an existing add_edge([...], dest) barrier

The gap

add_edge([source1, source2, ...], dest) creates a NamedBarrierValue channel: dest becomes a real AND-join, firing only once every named source has written to it since it last fired. This works great, as long as every source is a plain node reached only through add_edge.

If one of dest’s incoming connections instead comes from an add_conditional_edges branch, there is no way to make that branch’s write count as one of the barrier’s named sources. attach_branch’s get_writes always formats the destination as the generic per-node channel every node already gets by default (branch:to:{dest}, fires on any single write, no counting), and there’s no path in that code for “write into this existing join:... NamedBarrierValue channel by name instead.”

Consequence: if a conditional branch’s destination is also a NamedBarrierValue join, dest ends up with two independent trigger channels: the generic one (unguarded, fires on the very first write) and the real barrier (properly synchronized, fires only when complete). dest runs whenever either fires, so it fires early with incomplete data from the branch, and fires again later when the barrier itself completes.

This makes sense given the destination of a conditional edge isn’t known until the routing function runs, since the compiler can’t statically pre-register it as a fixed barrier source the way it can a plain edge. But once the routing function has run and picked a destination, that write is exactly as “real” as a static edge’s, and there’s currently no way to route it into a barrier by name.

Minimal repro

This needs a timing gap between the branch and the barrier’s other source(s) to actually show up: if everything resolves in the same super-step, Pregel’s trigger check happens to see both channels ready at once and only schedules the node once. So one track needs an extra hop to be one super-step “deeper” than the router’s branch: here a -> a2 is that extra hop, and b/router are both direct successors of split:

from langgraph.graph import END, START, StateGraph
from typing import Annotated, TypedDict
import operator


class State(TypedDict):
    calls: Annotated[list[str], operator.add]


def make_node(name):
    def _node(state):
        return {"calls": [name]}
    return _node


builder = StateGraph(State)
for name in ("split", "router", "a", "a2", "b", "join"):
    builder.add_node(name, make_node(name))

builder.add_edge(START, "split")
builder.add_edge("split", "router")
builder.add_edge("split", "a")
builder.add_edge("split", "b")
builder.add_edge("a", "a2")  # one extra hop, so this track resolves one super-step later

# The router's only branch goes straight to "join" -- no plain node in
# between, so it can never be named in join's barrier source list.
builder.add_conditional_edges("router", lambda state: "direct", {"direct": "join"})

# join's barrier only knows about "a2" and "b".
builder.add_edge(["a2", "b"], "join")
builder.add_edge("join", END)

graph = builder.compile()

calls = []
for step in graph.stream({"calls": []}, stream_mode="updates"):
    for node_name, update in step.items():
        calls.extend(update.get("calls", []))

print(calls)
print("join ran:", calls.count("join"), "times")  # -> 2, should be 1

Output: ['split', 'a', 'b', 'router', 'a2', 'join', 'join']. join runs twice: once as soon as router writes (through the generic channel, one super-step before a2 even runs), and once again when the ["a2", "b"] barrier legitimately completes.

Current workaround

Insert a synthetic no-op pass-through node between the branch and the join, so the branch’s destination becomes a plain node whose own single outgoing edge (add_edge(passthrough, join)) can be named in the barrier’s source list:

builder.add_node("router_passthrough", make_node("router_passthrough"))
builder.add_conditional_edges("router", lambda state: "direct", {"direct": "router_passthrough"})
builder.add_edge(["a2", "b", "router_passthrough"], "join")

With this, join runs exactly once: ['split', 'a', 'b', 'router', 'a2', 'router_passthrough', 'join'].

This works, but costs an extra node and an extra super-step for every conditional branch that needs to converge at a barrier join, purely as plumbing to route around the gap.

Request

Some way to let a add_conditional_edges path_map entry target a name already declared as a source of an existing barrier, so the branch’s write lands in that NamedBarrierValue channel instead of the generic per-node one, removing the need for the pass-through node. Even just exposing the join-channel name (e.g. via add_edge’s return value or a lookup) so a path_map could point directly at it would be enough; happy to sketch an API shape if useful.

Ran into this building a workflow compiler with real AND-splits where a conditional branch legitimately short-circuits straight to the join on some condition. It’s a fairly natural shape once branches and joins are both first-class.

1 Like