A run can die halfway: the container is replaced during a deploy, the host runs out of memory, the process is killed. ADK can pick that run up again. With resumability enabled on the App, the runner records which steps of an invocation finished, and a later run_async(..., invocation_id=...) continues from the first unfinished one. Here you build triage.py, a docs-triage agent that reads a page and files an issue in a tracker, kill its process inside the tool on purpose, and resume it from a second process. Then you fix the two problems the recorded runs expose: the tool runs again on resume, and ADK resumes any invocation ID you give it. These runs were recorded on Gemini gemini-3.8-flash.
When you need this. A docs-triage bot turns developer reports into tracker issues. A duplicate tracker issue wastes a developer’s time. When the bot’s process dies mid-run, you want the run finished, not restarted from the top, and you want a repeated call ID to return the existing issue.
Original recording. The saved output below uses the earlier fictional shipctl scenario. Current lab instructions and inputs use docuctl; this preserved output is not a recording of those renamed inputs.
$ python triage.py start --crash-after-write 'The tokens page says deploy tokens never expire. File a docs issue.'
session SESSION
invocation e-01ad461a-d0bb-4720-a38e-10244a18c017
docs_triage call read_page
docs_triage result read_page {"page": "tokens", "text": "# Authenticate shipctl in CI\n\n
docs_triage call file_issue
exit 137; tracker: [{'number': 1, 'key': 'call_615657'}]
$ python triage.py resume SESSION e-01ad461a-d0bb-4720-a38e-10244a18c017
Dropping function calls with no matching function response: ['call_615657']
invocation e-01ad461a-d0bb-4720-a38e-10244a18c017
docs_triage result file_issue {"issue": 1, "duplicate_request": true}
docs_triage 'Issue #1 has been filed.'
docs_triage end_of_agent
exit 0; tracker: [{'number': 1, 'key': 'call_615657'}]Lab setup. Download the complete lab ZIP, extract it and open a terminal in adk-resume/. Keep the bundled code, tests and fixtures together. Create the pinned environment before running the commands below:
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt1. Make the app resumable
Resumability is a setting on the App, the object that wraps the root agent with app-wide configuration:
"""File a docs issue from a developer's report, and resume the run if the process dies mid-tool.
Run from this folder with GOOGLE_API_KEY set:
python triage.py start "The tokens page says deploy tokens never expire. File a docs issue."
python triage.py resume SESSION_ID INVOCATION_ID
--crash-after-write is fault injection: the process exits inside file_issue, after the tracker
write and before ADK records the tool's result. Set DOCS_MODEL=ollama_chat/gemma4:e2b for Gemma.
"""
import argparse
import asyncio
import json
import os
from pathlib import Path
from filelock import FileLock
from google.adk import Agent, Runner
from google.adk.apps import App, ResumabilityConfig
from google.adk.models.lite_llm import LiteLlm
from google.adk.sessions import DatabaseSessionService
from google.adk.tools.tool_context import ToolContext
from google.genai import types
HERE = Path(__file__).resolve().parent
MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")
DB_URL = os.environ.get("DOCS_DB", f"sqlite+aiosqlite:///{HERE / 'sessions.db'}")
TRACKER = Path(os.environ.get("DOCS_TRACKER", HERE / "tracker.json")) # stands in for the issue tracker's API
APP, USER = "docs_triage", "dev-1"
FAULTS = {"crash_after_write": False, "idempotency_key": True}
def read_page(page: str) -> dict:
"""Read one page of the docuctl documentation: config, deploy, rollback or tokens."""
if page not in {"config", "deploy", "rollback", "tokens"}:
return {"error": f"No page {page!r}."}
path = HERE / "docs" / f"{page}.md"
return {"page": page, "text": path.read_text(encoding="utf-8")} if path.is_file() else {"error": f"No page {page!r}."}
def file_issue(title: str, body: str, tool_context: ToolContext) -> dict:
"""File one issue against the docs in the team tracker.
Args:
title: A short summary of the problem.
body: What the page says and what is wrong.
"""
# The stored call event supplies the same ID when a killed invocation is resumed.
key = tool_context.function_call_id if FAULTS["idempotency_key"] else None
if FAULTS["idempotency_key"] and not key:
raise ValueError("file_issue requires a function call ID")
with FileLock(str(TRACKER) + ".lock", timeout=10):
issues = json.loads(TRACKER.read_text(encoding="utf-8")) if TRACKER.exists() else []
for issue in issues:
if key and issue["key"] == key:
return {"issue": issue["number"], "duplicate_request": True}
issue = {"number": len(issues) + 1, "key": key, "title": title, "body": body}
pending = TRACKER.with_suffix(".pending")
pending.write_text(json.dumps(issues + [issue], indent=2) + "\n", encoding="utf-8")
os.replace(pending, TRACKER)
if FAULTS["crash_after_write"]:
os._exit(137) # like a SIGKILL: no exception, no cleanup, nothing more is written to the session
return {"issue": issue["number"]}
root_agent = Agent(
name="docs_triage",
model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
instruction="""You triage reports about the docuctl documentation.
Read the page the report is about with read_page once, then file exactly one issue with file_issue
that quotes what the page says. Reply with the issue number in one sentence.""",
tools=[read_page, file_issue],
generate_content_config=types.GenerateContentConfig(
temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
)
# Resumability is an App setting: the runner records which agent and tool steps finished.
app = App(name=APP, root_agent=root_agent, resumability_config=ResumabilityConfig(is_resumable=True))
def describe(event) -> str:
parts = event.content.parts if event.content and event.content.parts else []
what = [f"call {p.function_call.name}" if p.function_call else
f"result {p.function_response.name} {json.dumps(p.function_response.response)[:60]}" if p.function_response else
repr(p.text[:60]) for p in parts if p.function_call or p.function_response or (p.text and not p.thought)]
if event.actions.end_of_agent:
what.append("end_of_agent")
return f"{event.author:<12} {' + '.join(what) or '(no parts)'}"
async def run(session_id: str | None, text: str | None = None, invocation_id: str | None = None) -> list:
sessions = DatabaseSessionService(db_url=DB_URL)
runner = Runner(app=app, session_service=sessions)
if session_id is None:
session_id = (await sessions.create_session(app_name=APP, user_id=USER)).id
print("session", session_id, flush=True)
message = types.Content(role="user", parts=[types.Part(text=text)]) if text else None
events = []
try:
async for event in runner.run_async(user_id=USER, session_id=session_id, new_message=message, invocation_id=invocation_id):
if not events:
print("invocation", event.invocation_id, flush=True)
events.append(event)
print(describe(event), flush=True)
finally:
await runner.close()
return events
async def check_resumable(sessions, session_id: str, invocation_id: str) -> None:
"""Refuse a resume that would start a new model turn instead of finishing an interrupted one."""
session = await sessions.get_session(app_name=APP, user_id=USER, session_id=session_id)
if session is None:
raise SystemExit(f"no session {session_id}")
mine = [e for e in session.events if e.invocation_id == invocation_id]
if not mine:
raise SystemExit(f"invocation {invocation_id} is not in session {session_id}")
if any(e.actions.end_of_agent and e.author == root_agent.name for e in mine):
raise SystemExit(f"invocation {invocation_id} already finished")
async def resume(session_id: str, invocation_id: str) -> list:
await check_resumable(DatabaseSessionService(db_url=DB_URL), session_id, invocation_id)
return await run(session_id, invocation_id=invocation_id)
def main() -> None:
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
start_cmd = sub.add_parser("start")
start_cmd.add_argument("report")
start_cmd.add_argument("--crash-after-write", action="store_true")
start_cmd.add_argument("--no-idempotency-key", action="store_true")
resume_cmd = sub.add_parser("resume")
resume_cmd.add_argument("session_id")
resume_cmd.add_argument("invocation_id")
resume_cmd.add_argument("--no-idempotency-key", action="store_true")
args = parser.parse_args()
FAULTS["crash_after_write"] = getattr(args, "crash_after_write", False)
FAULTS["idempotency_key"] = not args.no_idempotency_key
if args.command == "start":
asyncio.run(run(None, text=args.report))
else:
asyncio.run(resume(args.session_id, args.invocation_id))
if __name__ == "__main__":
main()Two choices in triage.py make resume possible across processes. The session lives in SQLite through DatabaseSessionService, so the second process can read what the first one stored; an in-memory session dies with its process. And the App sets ResumabilityConfig(is_resumable=True), so the runner marks the end of the agent’s work with an end_of_agent event and, on resume, rebuilds the invocation from its stored events.
2. Kill the run inside the tool
--crash-after-write makes file_issue call os._exit(137) right after it writes the tracker file. That is the worst moment for a crash: the side effect happened, and ADK has stored the file_issue call event but not its result. The first command in the output at the top shows it: the process printed the call, exited with code 137, and the tracker held one issue.
From the extracted lab directory, set GOOGLE_API_KEY in your shell and choose writable paths for the session database and tracker:
export DOCS_DB=sqlite+aiosqlite:///resume-exercise.db
export DOCS_TRACKER=resume-exercise-tracker.json
python triage.py start 'The tokens page says deploy tokens never expire. File a docs issue.' --crash-after-write3. Resume the invocation
Copy the printed session and invocation values from the interrupted run, keep both environment variables unchanged, and pass those IDs to triage.py resume:
python triage.py resume SESSION_ID INVOCATION_IDThe resume command passes the session ID and the invocation ID that the first process printed. run_async gets no new message, only invocation_id. ADK logged that it was dropping a function call with no matching response, then ran file_issue again with the same call ID, called the model once for the final answer and ended with end_of_agent. The tool’s result was {"issue": 1, "duplicate_request": true}: the tracker already had an issue for that key.
4. Make the retried tool idempotent
The ADK resume docs state that a resumed tool runs at least once and may run more than once. file_issue uses tool_context.function_call_id as an idempotency key. The ID comes from the stored call event, so the retried call carries the same one, and the tool locks the local tracker around its lookup and write. It writes a complete replacement file and atomically replaces the old one, so concurrent callers cannot both create an issue for the same key. Here is the same crash and resume with --no-idempotency-key:
$ python triage.py start --crash-after-write --no-idempotency-key 'The tokens page says deploy tokens never expire. File a docs issue.'
session SESSION2
invocation e-2a839bbf-5ece-491b-bc3b-71f5161d895a
docs_triage call read_page
docs_triage result read_page {"page": "tokens", "text": "# Authenticate shipctl in CI\n\n
docs_triage call file_issue
exit 137; tracker: [{'number': 1, 'key': None}]
$ python triage.py resume --no-idempotency-key SESSION2 e-2a839bbf-5ece-491b-bc3b-71f5161d895a
Dropping function calls with no matching function response: ['call_609988']
invocation e-2a839bbf-5ece-491b-bc3b-71f5161d895a
docs_triage result file_issue {"issue": 2}
docs_triage 'Issue #2 has been filed.'
docs_triage end_of_agent
exit 0; tracker: [{'number': 1, 'key': None}, {'number': 2, 'key': None}][
{
"number": 1,
"key": null
},
{
"number": 2,
"key": null
}
]The tracker ended with 2 issues for one report. With a real tracker API, pass the key in the API’s own idempotency header or field if it has one, so the check and the write happen in one place.
A side effect repeated on resume. Because the crash came after the tracker write and before ADK stored the tool result, resuming without an idempotency key ran file_issue again and appended a second issue. Key every side effect on tool_context.function_call_id and deduplicate where the write happens.
Concurrent local writes. A separate lookup and write lets two callers create the same issue or overwrite each other. Hold the file lock across both operations and atomically replace the complete tracker JSON.
5. Refuse resumes that would start a new turn
Resuming is not a no-op when there is nothing to resume. Without a guard, resuming the finished invocation called the model again and appended a new answer:
# the second resume without check_resumable
docs_triage 'Issue #1 has been filed against the tokens documentation reg'
docs_triage end_of_agentcheck_resumable loads the session and refuses an invocation ID with no events in it, or one whose root agent already emitted end_of_agent:
$ python triage.py resume SESSION e-01ad461a-d0bb-4720-a38e-10244a18c017
invocation e-01ad461a-d0bb-4720-a38e-10244a18c017 already finished
exit 1; tracker: [{'number': 1, 'key': 'call_615657'}]
$ python triage.py resume SESSION e-0000
invocation e-0000 is not in session SESSION
exit 1; tracker: [{'number': 1, 'key': 'call_615657'}]Resuming a finished invocation. Without a guard, ADK called the model again and appended a new answer to a finished run. Check for the root agent’s end_of_agent event before calling run_async(invocation_id=...).
An invocation ID that is not in the session. Passing a mistyped ID such as e-0000 runs the agent on the session’s history as a new invocation. Check that the session holds events for that invocation_id before resuming.
How a completed tool call changes the resume point
Every event carries its invocation_id, so the session is also a log of how far each invocation got. With resumability on, a resume scans that invocation’s events: tool results that were stored are reused, a function call without a response is executed again, and the agent continues from there. The docs list two limits: do not change the agent tree between the crash and the resume, and custom agents must save their own step state to support resume. Resume from the ADK web UI or adk run is not supported; you call it from code or with invocation_id on the API server’s /run_sse.
The injected crash happens after the file replacement, before ADK records the tool response. The tests do not cover power loss or two processes resuming the same invocation at once; the resume guard is a preflight check, not a session lock.
Test the resume offline
test_triage.py replays the recorded model replies against a temporary SQLite database. It replaces os._exit with an exception that ADK does not catch, so the run stops inside file_issue without an error event, as the killed process did, and then resumes it in the same test.
python -m unittest -v test_triage.pytest_a_finished_invocation_is_refused (test_triage.Resume.test_a_finished_invocation_is_refused) ... ok
test_a_page_path_cannot_leave_the_named_docs (test_triage.Resume.test_a_page_path_cannot_leave_the_named_docs) ... ok
test_an_unknown_invocation_id_is_refused (test_triage.Resume.test_an_unknown_invocation_id_is_refused) ... ok
test_concurrent_calls_keep_one_issue_per_call_id (test_triage.Resume.test_concurrent_calls_keep_one_issue_per_call_id) ... ok
test_failed_replace_preserves_the_previous_tracker (test_triage.Resume.test_failed_replace_preserves_the_previous_tracker) ... ok
test_resume_reruns_the_interrupted_tool_with_the_same_call_id (test_triage.Resume.test_resume_reruns_the_interrupted_tool_with_the_same_call_id) ... ok
test_without_the_key_a_resume_files_a_duplicate (test_triage.Resume.test_without_the_key_a_resume_files_a_duplicate) ... ok
----------------------------------------------------------------------
Ran 7 tests in 1.124s
OKAll 7 tests pass.
Read the test and recording scripts
"""Replay the recorded crash and resume offline, against a temporary SQLite session database.
Run: python -m unittest -v test_triage.py
The model is replaced by its recorded responses. The crash is os._exit replaced by an exception
that ADK does not catch, so the run stops inside file_issue exactly as the killed process did.
"""
import asyncio
import json
import logging
import os
import tempfile
import unittest
import warnings
import time
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from pathlib import Path
from unittest import mock
warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)
from google.adk.models.base_llm import BaseLlm # noqa: E402
from google.adk.models.llm_response import LlmResponse # noqa: E402
from google.genai import types # noqa: E402
import triage # noqa: E402
RECORD = json.loads(Path(__file__).with_name("recorded-runs.json").read_text(encoding="utf-8"))
class Crash(BaseException):
"""Not an Exception, so ADK records no error event: the run just stops, like a killed process."""
class ReplayLlm(BaseLlm):
turns: list
calls: int = 0
async def generate_content_async(self, llm_request, stream=False):
self.calls += 1
yield LlmResponse(content=types.Content.model_validate(self.turns.pop(0)["content"]))
class Resume(unittest.TestCase):
def setUp(self):
warnings.simplefilter("ignore")
self.tmp = tempfile.TemporaryDirectory()
db = f"sqlite+aiosqlite:///{Path(self.tmp.name) / 'sessions.db'}"
self.tracker = Path(self.tmp.name) / "tracker.json"
self.model = ReplayLlm(model="replay", turns=list(RECORD["happy"]["model_turns"]))
agent = triage.root_agent.clone(update={"model": self.model})
self.patches = [mock.patch.object(triage, "DB_URL", db), mock.patch.object(triage, "TRACKER", self.tracker),
mock.patch.object(triage, "app", triage.app.model_copy(update={"root_agent": agent})),
mock.patch.object(triage, "root_agent", agent), mock.patch.dict(triage.FAULTS)]
for p in self.patches:
p.start()
def tearDown(self):
for p in reversed(self.patches):
p.stop()
self.tmp.cleanup()
def crash(self, idempotency_key: bool = True) -> tuple[str, str]:
triage.FAULTS.update(crash_after_write=True, idempotency_key=idempotency_key)
seen = []
with mock.patch.object(triage.os, "_exit", side_effect=Crash), mock.patch("builtins.print", lambda *a, **k: seen.append(a)):
with self.assertRaises(Crash):
asyncio.run(triage.run(None, text=RECORD["report"]))
triage.FAULTS["crash_after_write"] = False
return next(a[1] for a in seen if a[0] == "session"), next(a[1] for a in seen if a[0] == "invocation")
def issues(self) -> list:
return json.loads(self.tracker.read_text(encoding="utf-8"))
def test_concurrent_calls_keep_one_issue_per_call_id(self):
self.tracker.write_text("[]", encoding="utf-8")
context = SimpleNamespace(function_call_id="same-call")
triage.FAULTS.update(crash_after_write=False, idempotency_key=True)
original = Path.read_text
def slow_read(path, *args, **kwargs):
value = original(path, *args, **kwargs)
if path == self.tracker:
time.sleep(0.05)
return value
with mock.patch.object(Path, "read_text", slow_read), ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(lambda _: triage.file_issue("Title", "Body", context), range(8)))
self.assertEqual([i["number"] for i in self.issues()], [1])
self.assertEqual(sum(not r.get("duplicate_request", False) for r in results), 1)
def test_failed_replace_preserves_the_previous_tracker(self):
self.tracker.write_text("[]", encoding="utf-8")
triage.FAULTS.update(crash_after_write=False, idempotency_key=True)
with mock.patch.object(triage.os, "replace", side_effect=OSError("write interrupted")):
with self.assertRaises(OSError):
triage.file_issue("Title", "Body", SimpleNamespace(function_call_id="a-call"))
self.assertEqual(self.issues(), [])
def test_a_page_path_cannot_leave_the_named_docs(self):
with mock.patch.object(Path, "is_file", return_value=True), mock.patch.object(Path, "read_text", return_value="private"):
self.assertIn("error", triage.read_page("../secret"))
def test_resume_reruns_the_interrupted_tool_with_the_same_call_id(self):
sid, inv = self.crash()
self.assertEqual(len(self.issues()), 1)
with mock.patch("builtins.print"):
events = asyncio.run(triage.resume(sid, inv))
self.assertEqual([e.invocation_id for e in events], [inv] * len(events))
self.assertEqual(events[0].get_function_responses()[0].response, {"issue": 1, "duplicate_request": True})
self.assertTrue(events[-1].actions.end_of_agent)
self.assertEqual(len(self.issues()), 1)
self.assertEqual(self.model.calls, 3) # two before the crash, one for the final answer
def test_without_the_key_a_resume_files_a_duplicate(self):
sid, inv = self.crash(idempotency_key=False)
with mock.patch("builtins.print"):
asyncio.run(triage.resume(sid, inv))
self.assertEqual([i["number"] for i in self.issues()], [1, 2])
def test_a_finished_invocation_is_refused(self):
sid, inv = self.crash()
with mock.patch("builtins.print"):
asyncio.run(triage.resume(sid, inv))
with self.assertRaisesRegex(SystemExit, "already finished"):
asyncio.run(triage.resume(sid, inv))
self.assertEqual(self.model.calls, 3)
def test_an_unknown_invocation_id_is_refused(self):
sid, _ = self.crash()
with self.assertRaisesRegex(SystemExit, "invocation e-0000 is not in session"):
asyncio.run(triage.resume(sid, "e-0000"))
self.assertEqual(self.model.calls, 2)
if __name__ == "__main__":
os.environ.setdefault("PYTHONWARNINGS", "ignore")
unittest.main()"""Record a run that dies inside file_issue, its resume, and three ways resuming goes wrong.
Run from this folder with GOOGLE_API_KEY set: python record.py
Each process is a real `python triage.py ...` command against one SQLite session database.
Writes run-output.txt and recorded-runs.json; sessions.db and tracker.json are temporary.
"""
import asyncio
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPORT = "The tokens page says deploy tokens never expire. File a docs issue."
TMP = Path(tempfile.mkdtemp())
ENV = {**os.environ, "GOOGLE_GENAI_USE_ENTERPRISE": "0", "GOOGLE_GENAI_USE_VERTEXAI": "0", "PYTHONWARNINGS": "ignore",
"DOCS_DB": f"sqlite+aiosqlite:///{TMP / 'sessions.db'}", "DOCS_TRACKER": str(TMP / "tracker.json")}
os.environ.update({k: ENV[k] for k in ("DOCS_DB", "DOCS_TRACKER")})
import triage # noqa: E402 (reads DOCS_DB and DOCS_TRACKER)
def sh(*args: str) -> dict:
proc = subprocess.run([sys.executable, "triage.py", *args], cwd=HERE, env=ENV, capture_output=True, text=True, timeout=300)
lines = [l for l in proc.stdout.splitlines() if l.strip()]
err = [l for l in proc.stderr.splitlines() if l.startswith(("Dropping", "SystemExit")) or l.startswith("invocation ")]
return {"command": "python triage.py " + " ".join(a if " " not in a else repr(a) for a in args),
"stdout": lines, "stderr": err, "exit": proc.returncode}
def tracker() -> list:
path = TMP / "tracker.json"
return [{"number": i["number"], "key": i["key"]} for i in json.loads(path.read_text())] if path.exists() else []
async def model_turns(session_id: str) -> list:
"""The model's responses as the session stored them, for the offline replay tests."""
session = await triage.DatabaseSessionService(db_url=ENV["DOCS_DB"]).get_session(
app_name=triage.APP, user_id=triage.USER, session_id=session_id)
turns = []
for e in session.events:
if e.content and e.content.role == "model" and e.author == triage.APP:
parts = [{k: v for k, v in p.model_dump(mode="json", exclude_none=True).items() if k != "thought_signature"}
for p in e.content.parts]
turns.append({"invocation": e.invocation_id, "content": {"role": "model", "parts": parts}})
return turns
def ids(run: dict) -> tuple[str, str]:
return (next(l.split()[1] for l in run["stdout"] if l.startswith("session ")),
next(l.split()[1] for l in run["stdout"] if l.startswith("invocation ")))
def main() -> None:
rec = {"model": triage.MODEL, "report": REPORT, "runs": {}}
crash = sh("start", "--crash-after-write", REPORT)
sid, inv = ids(crash)
rec["runs"]["crash"] = {**crash, "tracker": tracker()}
rec["runs"]["resume"] = {**sh("resume", sid, inv), "tracker": tracker()}
rec["runs"]["resume_again"] = {**sh("resume", sid, inv), "tracker": tracker()}
rec["runs"]["unknown_invocation"] = {**sh("resume", sid, "e-0000"), "tracker": tracker()}
rec["happy"] = {"session": sid, "invocation": inv, "model_turns": asyncio.run(model_turns(sid))}
# What the guard prevents: the same two resumes, unguarded, call the model again.
events = asyncio.run(triage.run(sid, invocation_id=inv))
rec["unguarded_resume"] = [triage.describe(e) for e in events]
(TMP / "tracker.json").unlink()
crash = sh("start", "--crash-after-write", "--no-idempotency-key", REPORT)
sid2, inv2 = ids(crash)
rec["runs"]["crash_no_key"] = {**crash, "tracker": tracker()}
rec["runs"]["resume_no_key"] = {**sh("resume", "--no-idempotency-key", sid2, inv2), "tracker": tracker()}
rec["no_key"] = {"model_turns": asyncio.run(model_turns(sid2))}
out = []
for name, run in rec["runs"].items():
out += [f"$ {run['command']}", *run["stderr"], *run["stdout"], f"exit {run['exit']}; tracker: {run['tracker']}", ""]
out += ["# the second resume without check_resumable", *rec["unguarded_resume"]]
text = "\n".join(out).replace(sid, "SESSION").replace(sid2, "SESSION2")
(HERE / "run-output.txt").write_text(text + "\n", encoding="utf-8")
raw = json.dumps(rec, indent=2, ensure_ascii=False).replace(sid, "SESSION").replace(sid2, "SESSION2")
(HERE / "recorded-runs.json").write_text(raw + "\n", encoding="utf-8")
print(text)
if __name__ == "__main__":
main()- Rewind a session and migrate its database: undo turns in a session, and move an old session database to the current schema.
Check your understanding
Read the explanation
The resumed tool may execute again with the same function-call ID. The local lock covers lookup and write together.
References
Official sources checked October 21, 2026, against google-adk 2.12.0.