Repository navigation
Workflow(max_concurrency=1) fails on resume with "Replay divergence detected: Timed out waiting for sequence key": a replayed node waits for its turn while holding the only concurrency slot聽#7618
Description
馃敶 Required Information
Describe the Bug:
A Workflow with max_concurrency=1 runs a loop that asks for input twice (RequestInput), beside a chain of three plain nodes; both branches meet in a JoinNode. The first resume (answering ask-0) works. The second resume (answering ask-1) waits 15 s and raises WorkflowInvariantError: Replay divergence detected: Timed out waiting for sequence key 'answered@1' to be unblocked. The same workflow with max_concurrency=2 or None answers both requests and completes immediately. Nothing in the workflow depends on the concurrency limit, so a limit of 1 should only serialize the nodes, not make the run unresumable.
Steps to Reproduce:
pip install google-adk==2.12.0- Save the script under "Minimal Reproduction Code" as
repro.py(no model or API key is needed). - Run
python repro.py 1, thenpython repro.py none(or2).
Expected Behavior:
Both requests are answered and the run completes, as with max_concurrency=None:
google-adk 2.12.0, max_concurrency=none
answering ['ask-0']
answering ['ask-1']
completed in 0.0 s
Observed Behavior:
google-adk 2.12.0, max_concurrency=1
answering ['ask-0']
answering ['ask-1']
google.adk.workflow._errors.WorkflowInvariantError: Replay divergence detected: Timed out waiting for sequence key 'answered@1' to be unblocked.
(after a 15 s wait).
Environment Details:
- ADK Library Version (pip show google-adk): 2.12.0; also 2.11.0 and
mainat 1def14b - Desktop OS: macOS 15.7.3
- Python Version (python -V): Python 3.13.9
Model Information:
- Are you using LiteLLM: No
- Which model is being used: N/A (the reproduction uses no model)
馃煛 Optional Information
Regression:
No; 2.11.0, 2.12.0 and main behave the same.
Logs:
See "Observed Behavior".
Screenshots / Video:
N/A
Additional Context:
Analysis (paths under google/adk/workflow/, main at 1def14b):
- On resume, a node that already completed is fast-forwarded from the session history, and before returning it waits for its recorded turn on the
ReplaySequenceBarrier(_dynamic_node_scheduler.py,await self._replay_manager.wait_sequence(parent_path, key); the wait is inutils/_replay_sequence_barrier.py, 15 s timeout). - It waits inside its own node task, which stays in
loop_state.pending_tasks._schedule_ready_nodesstops starting nodes oncelen(loop_state.pending_tasks) >= max_concurrency(_workflow.py,_at_concurrency_limit). - On the second resume the trigger of
answered@1is buffered before that of the sibling-chain node recorded ahead of it, soanswered@1is started first, takes the only slot and waits for that node, which can then never be started. The barrier times out. - With a limit of 2 or no limit the earlier node gets a slot, the barrier advances, and the run completes.
This is a different case from #7502, whose fix buffers completions that arrive out of order: here no completion is dropped; the node the barrier waits for is never started. It does not reproduce without the loop (two requests in sequence), or with a sibling chain of one or two nodes.
Possible directions (untested): do not count a node that is only waiting on the replay barrier against max_concurrency (or wait on the barrier before taking a slot); or schedule replayed nodes in their recorded order.
Minimal Reproduction Code:
"""A Workflow with max_concurrency=1 deadlocks on its second resume: a loop asks twice for input beside a
three-node sibling chain. On replay, a completed node waits on the ReplaySequenceBarrier for its recorded
turn while holding the only concurrency slot, so the node recorded before it is never scheduled and the
barrier times out after 15 s. With max_concurrency=None or 2 both requests are answered and the run completes.
python adk-max-concurrency-replay-repro.py 1|2|none
"""
import asyncio
import sys
import time
from importlib.metadata import version
from google.adk.events.event import Event
from google.adk.events.request_input import RequestInput
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.workflow import START, FunctionNode, JoinNode, Workflow
from google.genai import types
ROUNDS = 2
def start(node_input: dict):
return {"round": 0}
async def ask(ctx, node_input: dict):
ctx.state["round"] = node_input["round"]
yield RequestInput(interrupt_id=f"ask-{node_input['round']}", message="approve?")
def answered(ctx, node_input):
return {"round": ctx.state["round"]}
def again(node_input: dict):
state = {"round": node_input["round"] + 1}
return Event(route=state["round"] < ROUNDS, output=state)
def other(node_input: dict):
return node_input
SIBLING = 3 # Two plain nodes in a row are not enough.
nodes = {name: FunctionNode(func=func, name=name) for name, func in
(("start", start), ("answered", answered), ("again", again))}
chain = [FunctionNode(func=other, name=f"other{i}") for i in range(SIBLING)]
asking = FunctionNode(func=ask, name="ask", rerun_on_resume=False)
join = JoinNode(name="join")
workflow = Workflow(
name="root",
edges=[(START, (nodes["start"], chain[0])), # A loop that asks twice, beside a chain of three nodes.
(nodes["start"], asking), (asking, nodes["answered"]), (nodes["answered"], nodes["again"]),
(nodes["again"], {True: asking, False: join}), *zip(chain, chain[1:]), (chain[-1], join)],
max_concurrency=None if sys.argv[1] == "none" else int(sys.argv[1]),
)
async def main():
sessions = InMemorySessionService()
runner = Runner(app_name="repro", node=workflow, session_service=sessions)
session = await sessions.create_session(app_name="repro", user_id="user")
message = types.Content(role="user", parts=[types.Part(text="{}")])
began = time.monotonic()
while True:
pending = []
async for event in runner.run_async(user_id="user", session_id=session.id, new_message=message):
if event.content:
pending += [part.function_call.id for part in event.content.parts or [] if part.function_call]
if not pending:
print(f"completed in {time.monotonic() - began:.1f} s")
return
print("answering", pending)
message = types.Content(role="user", parts=[types.Part(function_response=types.FunctionResponse(
id=call, name="adk_request_input", response={"ok": True})) for call in pending])
print(f"google-adk {version('google-adk')}, max_concurrency={sys.argv[1]}")
asyncio.run(main())How often has this issue occurred?:
- Always (100%)
Activity
widechaos commentedon Oct 20, 2026
I reproduced this on current main (
d5c87a66): the second resume times out withmax_concurrency=1, while2andNonecomplete. A local fix excludes tasks already marked as history-only replays from the execution-slot count. The Runner regression then completes both resumes without rerunning completed functions; separate resume tests still enforce the real execution limit. The workflow suite passes locally.May I submit a focused fix for this issue?
llalitkumarrr commentedon Oct 26, 2026
Hello @yifanxiong272,
Could you please try the patch below in
_at_concurrency_limitwithingoogle/adk/workflow/_workflow.pyand let us know your feedback?yifanxiong272 commentedon Oct 26, 2026
Thanks @llalitkumarrr, the patch fixes it for me.
I applied it to
_at_concurrency_limitonmainat bf67397 (google-adk 2.13.0, Python 3.13.9) and checked:max_concurrency=1, as it does with2andNone. Without the patch it still times out onanswered@1.RequestInputand three after it reach a peak concurrency of 1 withmax_concurrency=1and 2 with2, the same as without the patch.inamdarmihir commentedon Oct 27, 2026
Opened #7758.
_at_concurrency_limitcounted replay-only tasks (those tracked inloop_state.replayed_nodes), so a node waiting on the replay barrier held the only slot. It now counts only tasks that are really executing. I ran the repro from this issue withmax_concurrency=1: before, it raised the replay divergence timeout after about 15 s; after, both resumes complete. Added a Runner-based regression test that also asserts real concurrency stays limited.fix(workflow): exclude replayed nodes from max_concurrency count
fix(workflow): exclude replayed nodes from max_concurrency count