Repository navigation
JoinNode in a loop fires early with the previous iteration's predecessor outputs, then fires again #7498
Description
🔴 Required Information
Describe the Bug:
A JoinNode inside a loop fires before all of its predecessors have re-run, using outputs left over from the previous iteration, and then fires again when the rest arrive.
Workflow._buffer_barrier_trigger (src/google/adk/workflow/_workflow.py) treats the join as ready when every predecessor's NodeState.status == COMPLETED. That status is never consumed when the join fires, so on the next iteration a predecessor that has not re-run yet still counts as done. As soon as the first predecessor of iteration N completes, the join fires with the other predecessors' iteration N-1 outputs. Everything downstream of the join then runs an extra time per iteration.
This contradicts the documented contract (.agents/skills/adk-agent-builder/references/parallel-and-fanout.md): "A JoinNode waits for every predecessor, then fires once with all their outputs".
It is also visible with human-in-the-loop: if the slower branch pauses for RequestInput on iteration 2, the join fires with its stale iteration-1 output while it is still waiting for the user.
Steps to Reproduce:
pip install google-adk==2.11.0- Run the snippet below. One branch (
X -> B) is a hop longer than the other (A), so on the second iterationAcompletes first.
Expected Behavior:
One join firing per iteration, each with that iteration's outputs:
{'a': 'A1', 'b': 'B1'}
{'a': 'A2', 'b': 'B2'}
Observed Behavior:
The join fires twice in iteration 2, the first time with B's stale iteration-1 output:
{'a': 'A1', 'b': 'B1'}
{'a': 'A2', 'b': 'B1'}
{'a': 'A2', 'b': 'B2'}
Environment Details:
- ADK Library Version: 2.11.0 (also reproduced on
mainat bcac35a) - Desktop OS: macOS
- Python Version: 3.12.13
Model Information:
- Are you using LiteLLM: No
- Which model is being used: N/A (function nodes only)
🟡 Optional Information
Minimal Reproduction Code:
import asyncio
from google.adk.events.event import Event
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.workflow import Edge, JoinNode, START, Workflow, node
from google.genai import types
iteration = 0
join_inputs = []
def a(node_input):
global iteration
iteration += 1
return f"A{iteration}"
def x(node_input):
return "x"
def b(node_input): # one hop further from START than `a`
return f"B{iteration}"
def check(node_input):
join_inputs.append(node_input)
if len(join_inputs) < 2:
yield Event(route="again")
else:
yield Event(route="done", output="finished")
def end(node_input):
return node_input
A, X, B, C, E = node(a), node(x), node(b), node(check), node(end)
J = JoinNode(name="J")
wf = Workflow(
name="wf",
edges=[
Edge(from_node=START, to_node=A),
Edge(from_node=START, to_node=X),
Edge(from_node=X, to_node=B),
Edge(from_node=A, to_node=J),
Edge(from_node=B, to_node=J),
Edge(from_node=J, to_node=C),
Edge(from_node=C, to_node=A, route="again"),
Edge(from_node=C, to_node=X, route="again"),
Edge(from_node=C, to_node=E, route="done"),
],
)
async def main():
runner = Runner(app_name="app", node=wf, session_service=InMemorySessionService())
session = await runner.session_service.create_session(app_name="app", user_id="u")
async for _ in runner.run_async(
user_id="u",
session_id=session.id,
new_message=types.Content(role="user", parts=[types.Part(text="go")]),
):
pass
for i in join_inputs:
print(dict(sorted(i.items())))
asyncio.run(main())How often has this issue occurred?:
- Always (100%)
Proposed fix: Track, per wait-for-all node, which predecessors have completed since it last fired, and clear that set when it fires; check that set instead of NodeState.status. Resume is unaffected because resume replays recorded completions through the same completion path, which rebuilds the set. I have a PR ready with unit tests (plain loop, and a loop where the slower branch pauses for input, resumable and non-resumable).
Activity
surajksharma07 commentedon Oct 12, 2026
Reproduced this on 2.11.0 and got the same extra {'a': 'A2', 'b': 'B1'} firing on the second iteration @AkshayShah03.
Until it lands a possible workaround: a small Workflow subclass that tracks which predecessors arrived since the join last fired and clears that set on fire (same idea as your PR). That gave the expected [{A1, B1}, {A2, B2}] with your snippet. Could you check whether that holds up in your real workflow too especially the RequestInput pause/resume case?
Separately the None-output carry-over you mentioned is probably worth its own issue.
Please give the PR one more full run on your end so we know it's solid before review. Will keep this issue updated.
AkshayShah03 commentedon Oct 12, 2026
Thanks for reproducing it @surajksharma07. I gave the PR another full run on top of current main (ca3b490): it merges cleanly and
pytest tests/unittestspasses (18381 passed, 0 failed).On the pause/resume case: the PR's
test_join_node_in_loop_waits_for_predecessor_paused_for_inputcovers the slower branch pausing forRequestInputmid-loop, for both resumable and non-resumable apps. The join doesn't fire while it waits, and fires once with{A2, B2}after resume. The existing non-loop join +RequestInputresume case behaves the same as before.I'll file the None-output carry-over as its own issue.
AkshayShah03 commentedon Oct 12, 2026
Filed the None-output carry-over as #7515, with a few fix options. I'll wait for direction there before opening a PR.
DeanChensj commentedon Oct 12, 2026
Thanks — #7499 has been imported internally and is under review. Two notes:
None-output carry-over you filed as Workflow: node returning None in a later loop iteration leaves its previous output for JoinNode #7515 in the same change, so both land together.I'll update here once it merges.