Skip to content

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

@yifanxiong272

馃敶 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:

  1. pip install google-adk==2.12.0
  2. Save the script under "Minimal Reproduction Code" as repro.py (no model or API key is needed).
  3. Run python repro.py 1, then python repro.py none (or 2).

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 main at 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):

  1. 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 in utils/_replay_sequence_barrier.py, 15 s timeout).
  2. It waits inside its own node task, which stays in loop_state.pending_tasks. _schedule_ready_nodes stops starting nodes once len(loop_state.pending_tasks) >= max_concurrency (_workflow.py, _at_concurrency_limit).
  3. On the second resume the trigger of answered@1 is buffered before that of the sibling-chain node recorded ahead of it, so answered@1 is started first, takes the only slot and waits for that node, which can then never be started. The barrier times out.
  4. 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

  1. widechaos commented on Oct 20, 2026

    @widechaos

    I reproduced this on current main (d5c87a66): the second resume times out with max_concurrency=1, while 2 and None complete. 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?

  2. added
    core[Component] This issue is related to the core interface and implementation
    on Oct 22, 2026
  3. llalitkumarrr commented on Oct 26, 2026

    @llalitkumarrr
    Collaborator

    Hello @yifanxiong272,

    Could you please try the patch below in _at_concurrency_limit within google/adk/workflow/_workflow.py and let us know your feedback?

      def _at_concurrency_limit(self, loop_state: _LoopState) -> bool:
    
        if self.max_concurrency is None or self.max_concurrency <= 0:
          return False
        active = sum(
            1
            for name in loop_state.pending_tasks
            if name not in loop_state.replayed_nodes
        )
        return active >= self.max_concurrency
    
  4. added
    request clarification[Status] The maintainer need clarification or more information from the author
    on Oct 26, 2026
  5. yifanxiong272 commented on Oct 26, 2026

    @yifanxiong272
    Author

    Thanks @llalitkumarrr, the patch fixes it for me.

    I applied it to _at_concurrency_limit on main at bf67397 (google-adk 2.13.0, Python 3.13.9) and checked:

    • The reproduction from the issue now completes with max_concurrency=1, as it does with 2 and None. Without the patch it still times out on answered@1.
    • Variants with 2 to 4 rounds and a sibling chain of 3 or 5 nodes all complete. Each function runs the same number of times as with no limit, so no completed node is run again on resume.
    • The limit still holds for nodes that actually run: three parallel nodes before a RequestInput and three after it reach a peak concurrency of 1 with max_concurrency=1 and 2 with 2, the same as without the patch.
  6. inamdarmihir commented on Oct 27, 2026

    @inamdarmihir

    Opened #7758. _at_concurrency_limit counted replay-only tasks (those tracked in loop_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 with max_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.

  7. added 2 commits that reference this issue on Oct 27, 2026
    4a50020
    56f9a78
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

core[Component] This issue is related to the core interface and implementationrequest clarification[Status] The maintainer need clarification or more information from the author

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions