Repository navigation
Conversation
…pening an empty one A retry of the stateful toolset's -server-session Activity built a new toolset, and the next tool call opened a new, empty MCP session. Later calls in the run succeeded against it with no error. The session Activity now records a marker in its heartbeat details when its toolset opens an MCP session, before ADK sends a request on it. A later attempt that finds the marker fails with a non-retryable DedicatedWorkerFailure, as the provider docstring says. Retries in which no session was opened still build the toolset. All of the Activity's heartbeats go through one awaited client call, so a buffered heartbeat cannot erase the marker. A factory whose state survives being rebuilt recovered from these retries before; it now gets DedicatedWorkerFailure.
The session Activity sent its heartbeats as awaited client calls, and with a heartbeat timeout also as Worker heartbeats. Two writers meant a Worker heartbeat sent before the session opened could reach the server after the marker, and without a heartbeat timeout the Worker never saw cancels, pauses or resets in its own heartbeat responses. Now only activity.heartbeat() writes the details. The Worker sends one attempt's heartbeats in order and sends its latest details with the attempt's failure, so a graceful shutdown passes the marker on. With a heartbeat timeout, every heartbeat says a session may be open until one opens, and an attempt that ends without a session clears that, so only a lost or timed-out attempt is refused without a session. Workflow code goes back to main's: a list or call after a refused retry fails when its schedule-to-start timeout expires. The host-kill test now reads the ready line on Windows too.
…-stateful-mcp-session-replaced
…eset and host-kill tests
…t session Without a heartbeat timeout, the session Activity sent no details until a session opened, and the Worker could hold that marker heartbeat for up to 30 seconds. A host lost in that window was retried with empty details, so the retry rebuilt the toolset and the run read empty state. Now every attempt sends its first heartbeat at once with mcp-session-may-be-open, in every configuration, and before the first session opens it reads DescribeWorkflowExecution until the server shows that heartbeat. A lost or timed-out attempt is refused even when no session opened; a clean exit without a session still clears the marker. The Worker's client needs permission to describe the Workflow.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was changed
A retried stateful MCP session Activity no longer opens a new, empty MCP session in the middle of a run. If an earlier attempt opened a session, the retry fails with a non-retryable
ApplicationError(type="DedicatedWorkerFailure"), as theTemporalStatefulMcpToolSetProviderdocstring says. Retries in which no session was opened still build the toolset, except after a lost host or a timed-out attempt (below).-server-sessionActivity wrapscreate_session()on the toolset'sMCPSessionManager, which ADK'sMcpToolsetandMcpToolcall before every request. After ADK returns a session, and before ADK sends a request on it, the wrapper recordsmcp-session-openedin the Activity's heartbeat details withactivity.heartbeat(). A toolset with no session manager (such as a test fake) records it before its first list or call.DedicatedWorkerFailure,non_retryable=True, before it calls the factory.mcp-session-may-be-open, and every heartbeat carries it until a session opens. Before the first session opens, the session Activity readsDescribeWorkflowExecutionuntil the server shows that heartbeat. A retry that finds it is refused too.get_toolsand stateful tool calls fail withDedicatedWorkerFailureonce the session Activity has ended, with the session's failure as the cause. The caller does not wait for an operation that is still pending when the session ends: it fails at once, and the operation is cancelled. Withcancellation_type=WAIT_CANCELLATION_COMPLETEDthe operation is not cancelled but left to its own timeout, because no worker may be left to acknowledge the cancel, and an unacknowledged cancel keeps the Workflow from being evicted. Such an operation stays on thename@run_idqueue, which a laterasync withblock with the same name also serves. So the session worker refuses, with a non-retryableDedicatedWorkerFailure, any operation scheduled before its own session Activity: a run numbers its activities in order, and that number is the default activity ID. No operation is scheduled after the session ended. They no longer depend on an optionalschedule_to_start_timeout, which a caller-suppliedconfigmay leave out. There is no version gate (see Replay below).The marker is stored after ADK opens a session, not before the first operation: a first connection that fails raises before ADK returns a session, so its retry keeps working. Recording it after the operation would let a host lost after a tool call's side effect be rebuilt.
Earlier versions of this PR sent the marker with awaited client heartbeats as well as the Worker's. With two writers, a Worker heartbeat sent before the session opened could reach the server after the marker and erase it. Without a heartbeat timeout, resets also stopped reaching the session. This version has one writer and keeps the Worker's heartbeats.
What callers see after a refused retry: the run's next tool call, or one already waiting, fails with
DedicatedWorkerFailurewithout waiting for a timeout. On the agent route, ADK catches that error while preparing the turn, logs it, and runs the turn without the toolset (google/adk-pythondoes this for any toolset that fails to load). The run then fails when theasync withblock exits, becausecleanup()re-raises the session Activity's failure as anActivityErrorwhose cause is theDedicatedWorkerFailure.Breaking change (callers): a factory whose state survives being rebuilt, for example one that reloads it from storage, recovers today when the session Activity is retried. After this change, once a session was opened, that run gets
DedicatedWorkerFailureinstead. This also happens on a graceful deploy and after an Activity reset that keeps heartbeat details (reset_heartbeat=False). It also gets that error when its host is lost, or the attempt times out, before any session opened. Before upgrading, such a caller can move to the statelessTemporalMcpToolSetProviderandTemporalMcpToolSet, which the provider's docstring prefers when no persistent connection is needed, or catch the failure around theasync withblock and open a newTemporalStatefulMcpToolSetblock.Temporary limitation: a retry after a lost host or a timed-out attempt is refused even when no session was opened, because the plugin cannot wait for a Worker heartbeat. This will be replaced once the Python SDK offers a heartbeat the plugin can wait for.
Also: a reset with
reset_heartbeat=Trueclears the marker, so the next attempt builds the toolset. The session Activity sends one more heartbeat at its start, and before its first session it callsDescribeWorkflowExecutiononce or a few times, so the Worker's client needs permission to describe the Workflow. No public name, option or default changes.Replay: Workflow code changes, without a version gate:
[TMPRL1100], or with an ActivityMachine error for a history recorded before this change in which an operation was waiting.DedicatedWorkerFailureor a timeout. A Worker deploy that replays one of them mid-run will fail its Workflow task until it runs on the build that recorded it.Why?
See #310. Nothing told a retry of the session Activity apart from its first attempt, so a graceful Worker shutdown, the one-hour start-to-close timeout, or a heartbeat timeout with a lost host gave the run a new, empty MCP session, and later tool calls succeeded against it with no error.
Checklist
Closes [Bug] google_adk: a retried stateful MCP session Activity silently opens a new, empty MCP session #310
How was this tested:
Supplemental local runs in Docker, with
temporalio1.34.0,google-adk2.12.0 andmcp1.30.0 from the plugin's lock and Temporal CLI 1.9.1:cb83258):make sync,make format,make test PYTEST_ARGS='-n 4'(197 passed, 5 skipped),make lint,make buildandscripts/ci/check_conventions.pypass. After a typing fix in the new test,make lintandtest_stateful_mcp.py(20 passed, 3 runs) were rerun.test_stateful_operation_of_ended_session_not_run_by_next_block(new): the session's host shuts down gracefully after a session opened, aput('OLD-PAYLOAD')waits on the queue, the retried session is refused, and the Workflow catches the failure and opens a second block with the same name, whosegetmust returnEMPTY.WAIT_CANCELLATION_COMPLETEDthe second block's worker refuses the old put; withTRY_CANCELthe put is cancelled.cb83258's_mcp.py, theWAIT_CANCELLATION_COMPLETEDcase fails:getreturnsOLD-PAYLOAD. TheTRY_CANCELcase passes.test_stateful_session_after_host_killed[heartbeat_timeout_call_waiting](new): acall-toolwithWAIT_CANCELLATION_COMPLETEDand a 2-minute start-to-close timeout is running a tool that never returns when the session host is killed. The retried session is refused, and the run must fail within 10 seconds.ee42451's_mcp.py, the run never failed (pytest timeout after 120 s): the cancelled call waited for an acknowledgment, and the Worker loggedEviction processed, but 2 tasks remain.WAIT_CANCELLATION_COMPLETEDcancel that is never acknowledged blocks eviction, while an operation left uncancelled does not.call-tool(scratch test, default cancellation type): the run endsWORKFLOW_EXECUTION_CANCELEDwith the same history events onee42451and on this change.test_stateful_operations_fail_after_session_refused(start-to-close-onlyconfig, the session on its own host queue that shuts down gracefully after a session opened):list-toolswaiting, the refused retry cancels it and the run fails with the refusal.get_toolscalled after the refusal, nothing is scheduled and the run fails at once.8b32464's_mcp.py, both timed out.test_stateful_session_after_worker_replaced[first_connection_fails_delayed_heartbeats]:McpToolsetwhose first connection fails, "may be open" heartbeats that take 1.5 s to encode, and failure attributes encoded through a 3 s codec. The Worker shuts down gracefully while a heartbeat is being encoded.8b32464's_mcp.pyit is refused too.test_stateful_session_after_host_killed[no_heartbeat_timeout]kills the session host, with no heartbeat timeout, after a session opened behind a throttled heartbeat. On3b9ea40the retried attempt built a new toolset; on8b32464and with this change it is refused without a factory call.mainat88745dc, with only_HEARTBEAT_INTERVALand_SESSION_OPENEDadded so the tests can run, using679d134's tests:DID NOT RAISE WorkflowFailureError.assert 1 == 0(the new host called the factory).8b32464) fails as reported on 0.0.1 (google-adk2.13.0) and onmain. On8b32464its control reads its value, and the retried run fails withDedicatedWorkerFailurebecause the server heldmcp-session-may-be-open;getnever reached the MCP server.ee42451: the host-kill and refused-operation histories (default cancellation type) recorded on either build replay on both. A history in which aWAIT_CANCELLATION_COMPLETEDcall was running when the session ended, recorded on this change, fails onee42451with[TMPRL1100], as described under Replay above. The later worker-side refusal of an earlier block's operations changed no Workflow code, so it was not replayed again.8b32464(earlier round): completed and waiting runs recorded on8b32464and on this change replay on both builds. Refused runs in which a later or waitinglist-toolsmet the ended session fail across builds, as described under Replay above, and replay on the build that recorded them. Live carries through a Worker replacement were not rerun for this change.Any docs updates needed?
The google_adk README now covers the stateful toolsets' retry behaviour, how operations fail after the session ends (including
WAIT_CANCELLATION_COMPLETED, and that a later block with the same name does not run such an operation), and the heartbeat limitation. Release notes should name the caller change above.Not covered:
google-adk2.13.0 with this version, and the streamable HTTP and SSE transports in the tests (they use stdio).WAIT_CANCELLATION_COMPLETEDoperation runs on a lost host still waits for that operation's timeout, as onmain.WAIT_CANCELLATION_COMPLETEDoperation that had started on a lost host and is retried after its timeout keeps its activity ID, so a later block refuses it by the same check. This was reasoned from the code, not run.McpToolset._mcp_session_managerandMCPSessionManager.create_session(). These are private to ADK but the same fromgoogle-adk2.4.0 to 2.13.0. If ADK renames them, the toolset falls back to recording the marker before its first operation, which also refuses a retry after a first connection that failed.activity.heartbeat()call, astemporalio1.34.0 does. If it stops doing so, there is nothing to wait for.configorserver_session_configsets a non-numericactivity_id, the IDs are not compared and such an operation can run against a later block, as before.python/openai_agents/src/temporalio/openai_agents/_mcp.py) has the same session Activity code and is not changed here.Open questions: none.
Draft while the caller change is reviewed.