Skip to content

Resume fails with "Replay divergence detected" on SqliteSessionService when a parallel branch finishes during a request_input model call聽#7695

Open

Description

@dwco-z

馃敶 Required Information

Describe the Bug:

I have a workflow with two parallel branches. One runs an LlmAgent that can pause the run with request_input; the other finishes on its own. If the other branch finishes while the agent's model call is still running, resuming the session with SqliteSessionService (or DatabaseSessionService) fails after 15s with:

WorkflowInvariantError: Replay divergence detected: Timed out waiting for sequence key 'side_branch@1' to be unblocked.

The same flow works with InMemorySessionService.

From what I can tell, the adk_request_input event gets its timestamp when the model call starts (base_llm_flow.py, _run_one_step_async), not when the response comes back. It's appended after the other branch's output but has an earlier timestamp. The SQL session services reload events ordered by timestamp, so after a reload the ask comes first. The replay then makes side_branch wait for asker, which reruns its model call and only unblocks the barrier when the call finishes, and anything slower than 15s fails.

Steps to Reproduce:

  1. pip install google-adk==2.12.0
  2. Run the script below (no API key needed, it uses a fake model).

Expected Behavior:
The resume completes, as it does with InMemorySessionService.

Observed Behavior:

order after reload: ['root@1/asker@1/agent@1', 'root@1/side_branch@1']
resume failed after 15.1s: Replay divergence detected: Timed out waiting for sequence key 'side_branch@1' to be unblocked.

side_branch finished first, but it's loaded second.

Environment Details:

  • ADK Library Version: 2.12.0 (the same code is on main)
  • Desktop OS: Windows 11
  • Python Version: 3.14.8

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: Gemini Flash (the repro uses a fake model)

馃煛 Optional Information

Minimal Reproduction Code:

import asyncio
import tempfile
import time
from pathlib import Path

from google.adk.agents import Context, LlmAgent
from google.adk.apps import App
from google.adk.apps._configs import ResumabilityConfig
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import Runner
from google.adk.sessions.sqlite_session_service import SqliteSessionService
from google.adk.tools import request_input
from google.adk.workflow import START, JoinNode, Workflow, node
from google.genai import types


class FakeLlm(BaseLlm):
    model: str = "fake"
    calls: int = 0

    async def generate_content_async(self, llm_request, stream=False):
        self.calls += 1
        if self.calls == 1:
            # First call: asks the user for input after 1s.
            await asyncio.sleep(1)
            ask = types.FunctionCall(id="ask-1", name="adk_request_input", args={"message": "Which one?"})
            yield LlmResponse(content=types.Content(role="model", parts=[types.Part(function_call=ask)]))
        else:
            # After the resume: a call slower than the 15s replay barrier timeout.
            await asyncio.sleep(16)
            yield LlmResponse(content=types.Content(role="model", parts=[types.Part(text="done")]))


agent = LlmAgent(name="agent", model=FakeLlm(), instruction="Ask if unsure.", tools=[request_input])


@node(rerun_on_resume=True)
async def asker(ctx: Context, node_input):
    yield await ctx.run_node(agent, node_input="question")


@node(rerun_on_resume=True)
async def side_branch(ctx: Context, node_input):
    await asyncio.sleep(0.5)  # finishes while the asker's model call is still running
    yield "side done"


@node
async def end(ctx: Context, node_input):
    yield node_input


async def main():
    workflow = Workflow(name="root", edges=[(START, (asker, side_branch), JoinNode(name="join"), end)])
    app = App(name="repro", root_agent=workflow, resumability_config=ResumabilityConfig(is_resumable=True))
    sessions = SqliteSessionService(str(Path(tempfile.mkdtemp()) / "sessions.db"))
    runner = Runner(app=app, session_service=sessions)
    session = await sessions.create_session(app_name="repro", user_id="u")

    async def send(part):
        message = types.Content(role="user", parts=[part])
        return [e async for e in runner.run_async(user_id="u", session_id=session.id, new_message=message)]

    events = await send(types.Part(text="go"))
    ask = next(c for e in events for c in e.get_function_calls())

    reloaded = await sessions.get_session(app_name="repro", user_id="u", session_id=session.id)
    print("order after reload:", [e.node_info.path for e in reloaded.events if e.output is not None or e.get_function_calls()])

    reply = types.FunctionResponse(id=ask.id, name=ask.name, response={"result": "the first"})
    started = time.time()
    try:
        await send(types.Part(function_response=reply))
        print("resume OK")
    except Exception as exc:
        print(f"resume failed after {time.time() - started:.1f}s: {exc}")


asyncio.run(main())

Workaround:
A plugin that re-stamps the ask when it's appended makes the script pass:

class RestampAsk(BasePlugin):
    async def on_event_callback(self, *, invocation_context, event):
        if any(c.name == "adk_request_input" for c in event.get_function_calls()):
            event.timestamp = platform_time.get_time()

It has to change the event in place, because a returned replacement event loses its timestamp.

Possible fix:
Set the model response event's timestamp when the response arrives, or order the replay by append order instead of by timestamp.

How often has this issue occurred?:

  • Always (100%) with the script. In our app, it happens whenever the other branch finishes during the ask's model call.

Activity

  1. 2Zest commented on Oct 23, 2026

    @2Zest

    Thanks for the detailed, no-API-key reproduction. I鈥檇 like to take this on, pending maintainer confirmation.

    Proposed minimal scope:

    • determine whether persisted-session replay should use append/causal ordering or response-time timestamps;
    • add an isolated regression test with the fake LLM and parallel request_input;
    • apply the smallest shared fix for SqliteSessionService / DatabaseSessionService, without changing InMemorySessionService or the existing timeout policy.

    I鈥檒l keep it focused and report the relevant unit-test results in a PR. Does this approach align with the intended replay semantics?

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions