Skip to content

bug(graph): OR-join node double-executes when 2+ of its predecessors land in the same dispatch batch #3628

Closed as not planned

Description

@strandly-the-agent

Found while reviewing PR #3448 (the max_concurrency feature) — confirmed present on main / with no max_concurrency set, so it's unrelated to that PR. Filing separately per review convention.

Repro topology: x -> a0..a4, x -> bb, a0..a4 -> bb (a fan-out from x into 5 siblings that all also feed bb, plus bb also depends directly on x).

Batch 1 = [x]. Once x completes, _find_newly_ready_nodes([x]) (graph.py:981) correctly adds a0..a4 and bb to batch 2, since bb has a direct x -> bb edge — this is intended OR-join behavior.

Batch 2 = [a0, a1, a2, a3, a4, bb] all dispatch together. After batch 2 finishes, _execute_graph (graph.py:829-830) calls _find_newly_ready_nodes(current_batch) with current_batch = all 6 nodes just dispatched, bb included. _is_node_ready_with_conditions (graph.py:996) only checks "is there a satisfied incoming edge from something in this batch" — it does not exclude the target node itself from being re-evaluated, and does not check whether the target already ran. Since a0->bb (and a1->bb, etc.) are real edges and a0..a4 are members of the batch that just completed, bb is deemed "newly ready" again and gets dispatched a second time in batch 3 — i.e. bb.stream_async/invoke_async runs twice for one logical graph execution.

This reproduces with max_concurrency unset (None), using only currently-shipped, unmodified _find_newly_ready_nodes/_is_node_ready_with_conditions/_execute_graph (verified byte-identical to main while reviewing #3448).

Minimal repro (pytest, drop into tests/strands/multiagent/)
from .test_graph import create_mock_agent
from strands.hooks import BeforeNodeCallEvent
from strands.multiagent.graph import GraphBuilder
import pytest

@pytest.mark.asyncio
async def test_or_join_double_fires_within_batch():
    n = 5
    builder = GraphBuilder()
    builder.add_node(create_mock_agent("x", "x done"), "x")
    builder.set_entry_point("x")
    for i in range(n):
        builder.add_node(create_mock_agent(f"a{i}", f"a{i} done"), f"a{i}")
        builder.add_edge("x", f"a{i}")
    agent_bb = create_mock_agent("bb", "bb done")
    builder.add_node(agent_bb, "bb")
    builder.add_edge("x", "bb")
    for i in range(n):
        builder.add_edge(f"a{i}", "bb")
    graph = builder.build()  # no max_concurrency set

    calls = []
    graph.add_hook(lambda e: calls.append(e.node_id) if e.node_id == "bb" else None, BeforeNodeCallEvent)

    await graph.invoke_async("task")
    assert len(calls) == 1, f"bb's BeforeNodeCallEvent fired {len(calls)} times, expected 1"

Observed: bb's BeforeNodeCallEvent fires twice, and agent_bb.stream_async is called twice, for a single invoke_async. The final GraphResult still reports Status.COMPLETED, so this is silent (extra cost/side-effects for a non-idempotent node, doubled token usage, no visible error).

Suspected root cause: _find_newly_ready_nodes/_is_node_ready_with_conditions (graph.py:981-1013) treat every member of the just-completed current_batch as a fresh "source" for edge-satisfaction checks, without excluding edges whose target is also a member of that same batch (i.e. a node that already ran this batch via an earlier-satisfied OR-edge shouldn't be re-armed by a later-satisfied edge from a batch-mate).

Not urgent/security-sensitive, but worth a look — needs an idempotency/already-completed guard in _find_newly_ready_nodes or _execute_node, and a regression test for a node with ≥2 real incoming edges that land in the same batch.

Activity

  1. strandly-the-agent commented on Aug 4, 2026

    @strandly-the-agent
    CollaboratorAuthor

    Retracting this — I got the root cause wrong, and on closer reading the behavior is documented rather than a defect. Apologies for the noise.

    The stated trigger is wrong. "2+ predecessors in the same dispatch batch" does not cause a re-fire: x -> a, x -> b, a -> z, b -> z runs z exactly once (12/12 runs on 1b671d6a), because _find_newly_ready_nodes de-dupes candidates into a set (graph.py:988). This repo's own passing test_graph_execution is that shape — multi_node -> final_node and conditional_agent -> final_node both land in batch 2 (test_graph.py:192-195) and it asserts final_agent.stream_async.call_count == 1 (test_graph.py:253). If the mechanism I described were real, that test would fail.

    What actually re-fires bb in my repro is the direct x -> bb edge firing it early, then a later batch satisfying another incoming edge. The minimal case needs one sibling, not five (x -> a, x -> bb, a -> bb) — so the topology I picked obscured the cause rather than isolating it.

    And that is documented OR semantics, not a bug. graph.mdx:277: "a target node fires when any incoming edge's source completes. Use conditional edges to explicitly wait for all dependencies." The docs' own all_dependencies_complete recipe makes bb fire exactly once (verified 12/12), and the AND-mode gap is already tracked in #1081 with #1530 open against it. Python also has no exactly-once-per-invocation guarantee — set_max_node_executions exists precisely because nodes re-run, reset_executor_state's docstring says it is "useful when nodes are executed multiple times", and several passing tests assert nodes running 2–3x per invoke (test_graph.py:435, 1199, 1260, 1290).

    The second bb run also receives strictly more input than the first (['x done'] vs ['x done', 'a0 done' … 'a4 done']), which is the intended "fire again when another edge is satisfied" behavior rather than duplicated work.

    Closing as not-a-bug. The one useful residue is the docs question of whether OR re-firing deserves a more explicit callout, which belongs on #1081 rather than here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-multiagentMulti-agent relatedbugSomething isn't workingpythonPull requests that update python code

    Type

    Fields

    Language

    Python

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions