Repository navigation
Description
🔴 Required Information
Describe the Bug:
A resumable Workflow with two parallel RequestInput pauses fails when both are answered in one message. The resume raises WorkflowInvariantError: Replay divergence detected: Timed out waiting for sequence key 'step2@1' to be unblocked. after the 15 s barrier timeout.
The workflow forks into three branches that meet at a JoinNode: a chain of three nodes, a pause after one node, and a pause right after the fork. Pauses are FunctionNodes with rerun_on_resume=True.
Steps to Reproduce:
pip install google-adk==2.11.0- Run the script below. It answers both pending requests in one message.
Expected Behavior:
pending: ['confirm', 'approve']
pending: []
completed
Observed Behavior:
pending: ['confirm', 'approve']
google.adk.workflow._errors.WorkflowInvariantError: Replay divergence detected: Timed out waiting for sequence key 'step2@1' to be unblocked.
It does not fail with a single pause, or with a chain of one or two nodes. Whether it fails depends on the order of the fan-out branches.
Environment Details:
- ADK Library Version: 2.11.0 (also reproduced on
mainat bca4a42) - Desktop OS: macOS 15.7.3
- Python Version: 3.13.9
Model Information:
- Are you using LiteLLM: No
- Which model is being used: N/A (function nodes only, no LLM)
🟡 Optional Information
Minimal Reproduction Code:
import asyncio
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
def node(name):
def run(node_input: dict):
return node_input
return FunctionNode(func=run, name=name)
def pause(name):
async def ask(ctx, node_input: dict):
if name in ctx.resume_inputs:
yield Event(output=node_input)
return
yield RequestInput(interrupt_id=name, message=f"{name}?")
return FunctionNode(func=ask, name=name, rerun_on_resume=True)
fork, prepare, join = node("fork"), node("prepare"), JoinNode(name="join")
approve, confirm = pause("approve"), pause("confirm")
step0, step1, step2 = node("step0"), node("step1"), node("step2")
workflow = Workflow(name="root", edges=[
(START, fork), (fork, (step0, prepare, confirm)),
(step0, step1), (step1, step2), (step2, join), # A chain of three nodes.
(prepare, approve), (approve, join), # A pause after one node.
(confirm, join), # A pause right after the fork.
])
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='{"x": 0}')])
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]
print("pending:", pending)
if not pending:
print("completed")
return
# Answer every pending request in one message.
message = types.Content(role="user", parts=[types.Part(function_response=types.FunctionResponse(
id=interrupt_id, name="adk_request_input", response={"ok": True})) for interrupt_id in pending])
asyncio.run(main())Possible cause:
On resume, ReplayManager._scan_sequence records the completion order of the root's children from the first run:
['fork@1', 'step0@1', 'prepare@1', 'confirm@1', 'step1@1', 'approve@1', 'step2@1']
During replay, children are handled in two different ways (workflow/_dynamic_node_scheduler.py):
- A fast-forwarded child goes through
_check_existing_run, which callswait_sequencebefore returning, so it waits for its turn. - A child that must run again (here the two answered pauses, via the "Rerun!" branch) does not wait. It runs live, and when it finishes, the scheduler calls
advance_sequence.
ReplaySequenceBarrier.check_and_advance only advances when the key is the one currently expected, and otherwise ignores it. Instrumenting the barrier shows:
advance confirm@1 ok
advance approve@1 ignored (expected step1@1)
advance step1@1 ok
wait step2@1 -> times out; approve@1 never advances again
So a rerun child that finishes ahead of its recorded position loses its completion, and the barrier never reaches the next key. Nested Workflow nodes take the same rerun path, so the same timeout also appears when such a node finishes out of order.
Possible fix:
Keep completions that arrive early, and apply them when their turn comes. With this patch applied, the script above completes:
def check_and_advance(self, key: str) -> None:
early = self.__dict__.setdefault("early", set())
if key in self.sequence[self.current_index:]:
early.add(key)
while self.current_index < len(self.sequence) and self.sequence[self.current_index] in early:
early.discard(self.sequence[self.current_index])
self.current_index += 1
if self.current_index < len(self.sequence):
self.events[self.sequence[self.current_index]].set()An alternative is to have rerun children wait on the barrier before they advance it.
Activity
yifanxiong272 commentedon Oct 11, 2026
Follow-up: a single pause is enough to trigger this. The original report answers two parallel pauses in one message, but the same deadlock happens with one
RequestInputwhen the parallel branches are sub-workflows.Below, one branch is a two-node sub-workflow followed by a node, and the other is a two-node sub-workflow whose first node pauses once. Answering that single request fails the same way:
Expected:
Reproduced on 2.11.0 and on
mainat bca4a42 (InMemorySessionService, no LLM). Replacing either sub-workflow with a plain node makes it pass. Thecheck_and_advancepatch from the original report also makes it pass.So batching answers is not required. Nested
Workflownodes also take the rerun path during replay, so they advance the barrier without waiting. When one finishes ahead of its recorded position, its completion is dropped, as in the original trace.pei711 commentedon Oct 12, 2026
I reproduced the replay deadlock and can take this issue. My proposed fix is to buffer child completions that arrive before their recorded turn, then advance the barrier through any buffered completions once they reach the head of the sequence. I would add focused barrier tests and a workflow regression test for the single-pause nested-workflow case. Please confirm this approach is acceptable before I start the patch.
sanketpatil06 commentedon Oct 13, 2026
Thanks @yifanxiong272 for the detailed report and follow-up. I've reproduced the single-pause nested-workflow case on
google-adk==2.11.0(timeout onreport@1), so this is confirmed as a bug inReplaySequenceBarrier.@pei711 your approach looks right, buffer early completions and advance through them once they reach the head of the sequence. Please go ahead and open a PR with tests covering both repros.
Thanks.
fix(workflow): buffer out-of-order completions in ReplaySequenceBarrier