An agent run that takes minutes can stop halfway: a deploy restarts the worker, the machine runs out of memory, a pod is evicted. Without saved state, the next attempt starts from the beginning and pays for every model call again. LangGraph saves the graph state after each step to a checkpointer, so the next process can continue where the last one stopped. In this lesson the review team’s release-notes workflow collects merged pull requests, has Gemini (gemini-3.8-flash) write the notes, writes the file and posts an announcement. In the recorded kill after the model step, the next process resumed at write_file without another model call:
When you need this. The release-notes worker can be redeployed between the model call and the announcement. Save the completed work so the next process can finish the pending nodes. The recording below tests that between-step restart and separately shows why a side effect inside an interrupted node can repeat.
1. Build the workflow
Download the complete lab, extract it and open a terminal in build-a-workflow-with-checkpoints-and-resumable-steps/. The README lists its runtime and recorded examples.
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txtUse the extracted lab and its virtual environment. The input is the list of merged pull requests:
[
{"number": 409, "title": "Add a per-client timeout override", "labels": ["feature"]},
{"number": 412, "title": "Retry webhook delivery on 503", "labels": ["bug", "webhooks"]},
{"number": 418, "title": "Remove the unused legacy CSV export", "labels": ["cleanup"]},
{"number": 422, "title": "Bump httpx to 0.28.1", "labels": ["dependencies"]}
]"""The review team's release-notes workflow. Its state is checkpointed to SQLite after every step, so a crashed run resumes."""
import argparse
import json
import os
from pathlib import Path
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_ollama import ChatOllama
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph
from typing_extensions import TypedDict
HERE = Path(__file__).parent
OUT = HERE / "out"
MODEL = os.environ.get("REVIEW_MODEL", "gemini-3.8-flash") # REVIEW_MODEL=gemma4:e2b runs it on Ollama
class State(TypedDict, total=False):
version: str
prs: list[dict]
notes: str
path: str
def crash_point(node: str) -> None:
"""Kill the process at a named point, the way an OOM kill or a redeploy would."""
if os.environ.get("CRASH_AT") == node:
print(f"!! process killed in {node}", flush=True)
os._exit(1)
def chat_model():
if MODEL.startswith("gemini"):
return ChatGoogleGenerativeAI(model=MODEL, max_output_tokens=1024, max_retries=0, timeout=30)
return ChatOllama(model=MODEL, temperature=0, reasoning=False)
def build_graph(checkpointer, model=None):
def collect(state: State) -> dict:
prs = json.loads((HERE / "prs.json").read_text())
return {"prs": [pr for pr in prs if "dependencies" not in pr["labels"]]}
def summarize(state: State) -> dict:
lines = "\n".join(f"#{pr['number']} {pr['title']}" for pr in state["prs"])
reply = (model or chat_model()).invoke(
f"Write release notes for version {state['version']}: one Markdown bullet per merged pull request, "
f"user-facing wording, keep the #number.\n{lines}")
return {"notes": reply.text}
def write_file(state: State) -> dict:
path = OUT / f"{state['version']}.md"
path.parent.mkdir(exist_ok=True)
path.write_text(f"# Release {state['version']}\n\n{state['notes']}\n")
return {"path": f"{OUT.name}/{path.name}"}
def announce(state: State) -> dict:
with open(OUT / "announcements.log", "a") as log: # stands in for a chat or email post
log.write(f"Released {state['version']}: {state['path']}\n")
crash_point("announce") # dies after the post, before LangGraph saves this step
return {}
graph = StateGraph(State)
for name, node in [("collect", collect), ("summarize", summarize), ("write_file", write_file), ("announce", announce)]:
graph.add_node(name, node)
graph.add_edge(START, "collect")
graph.add_edge("collect", "summarize")
graph.add_edge("summarize", "write_file")
graph.add_edge("write_file", "announce")
graph.add_edge("announce", END)
return graph.compile(checkpointer=checkpointer)
def run(graph, thread_id: str, version: str, durability: str = "sync") -> None:
config = {"configurable": {"thread_id": thread_id}}
saved = graph.get_state(config)
if saved.next: # an unfinished run on this thread: resume it from its last checkpoint
print(f"resuming {thread_id} at {saved.next[0]}")
stream = graph.stream(None, config, stream_mode="updates", durability=durability)
else:
stream = graph.stream({"version": version}, config, stream_mode="updates", durability=durability)
for update in stream:
print("finished", *update)
crash_point(f"after {next(iter(update))}")
if __name__ == "__main__":
args = argparse.ArgumentParser()
args.add_argument("--thread", required=True)
args.add_argument("--version", default="2.5.0")
args.add_argument("--durability", default="sync", choices=["sync", "async", "exit"])
args.add_argument("--work-dir", type=Path, default=HERE,
help="Directory for checkpoints.db and out/; reuse it when resuming the same thread.")
a = args.parse_args()
a.work_dir.mkdir(parents=True, exist_ok=True)
OUT = a.work_dir / "out"
with SqliteSaver.from_conn_string(str(a.work_dir / "checkpoints.db")) as saver:
run(build_graph(saver), a.thread, a.version, a.durability)The four nodes run in a line: collect drops dependency bumps, summarize calls the model, write_file writes out/<version>.md, and announce appends to out/announcements.log, which stands in for a chat post. crash_point kills the process with os._exit(1), which skips every finally block and cleanup handler, the way an out-of-memory kill does. An exception would be a gentler test than a real crash.
The current Gemini client sets max_output_tokens=1024, max_retries=0 and a 30-second request timeout. An output limit does not mean every pull request was summarized: this workflow saves the returned text without checking whether generation stopped at that limit. Read the notes before using them. A failed summarize request leaves that node unfinished, so resuming it can call the model again. The historical scenario recordings below predate these explicit transport limits. The new one-request demonstration in Step 4 uses the current limits; offline checks separately inspect the serialized SDK request.
2. Save each step to SQLite
SqliteSaver.from_conn_string("checkpoints.db") opens the file and creates its tables, and compile(checkpointer=saver) makes every run of the graph save its state after each step, under the thread_id in the config. Pick the checkpointer by where the next process runs. InMemorySaver lives in the process, so it can’t survive a crash at all. SQLite survives a process on one machine. Workers on several machines need a shared database; langgraph-checkpoint-postgres provides PostgresSaver.
durability="sync" tells LangGraph to finish writing each checkpoint before the next step starts. The default, "async", writes while the next step runs, which is faster but can lose the last step if the process dies at the wrong moment. "exit" writes only when the run ends.
3. Resume on the same thread
Saving state is only useful if the next process looks for it, so now we teach run() to check first. run() asks the checkpointer before it starts. graph.get_state(config).next names the nodes still to run on that thread. If it is not empty, the last run did not finish, and graph.stream(None, config) resumes it: None means “no new input, continue from the checkpoint”. Passing the input again would start a new run on the same thread instead.
4. Crash it in real processes
Create a separate output directory for your run. --work-dir selects both its SQLite file and its out/ folder; it leaves earlier runs in other directories alone. The first command intentionally exits with code 1 after summarize. Run the second command afterwards with the same directory and thread to resume at the pending step.
CRASH_AT="after summarize" python workflow.py --work-dir runs/release-2-5 --thread release-2-5 --version 2.5.0
python workflow.py --work-dir runs/release-2-5 --thread release-2-5 --version 2.5.0Use a fresh directory and thread when repeating the experiment. Without CRASH_AT, the same workflow.py command starts a normal positive run. The lab README contains the environment and backend setup.
Inspect the actual crash and resume
In the saved two-process run, the first process completed the model step and intentionally exited with code 1. SQLite retained next = ["write_file"]. Resuming the same directory and thread wrote the notes and one local announcement without another model call.
$ CRASH_AT="after summarize" /Users/arjunprabhulal/aiml-lanes/envs/venv-agents/bin/python workflow.py --work-dir /private/tmp/aiml-langgraph-checkpoint-native-preparation-20261023/approved-native-crash-resume-run/work --thread bounded-release-2-5 --version 2.5.0
finished collect
finished summarize
!! process killed in after summarize
Actual process exit: 1
Saved checkpoint next: ["write_file"]
$ /Users/arjunprabhulal/aiml-lanes/envs/venv-agents/bin/python workflow.py --work-dir /private/tmp/aiml-langgraph-checkpoint-native-preparation-20261023/approved-native-crash-resume-run/work --thread bounded-release-2-5 --version 2.5.0
resuming bounded-release-2-5 at write_file
finished write_file
finished announce
Actual process exit: 0
Saved checkpoint next: []
Actual generated Markdown: out/2.5.0.md
# Release 2.5.0
## Version 2.5.0
* Added the ability to configure custom timeout overrides for individual clients (#409).
* Improved webhook reliability by automatically retrying delivery when receiving a 503 (Service Unavailable) response (#412).
* Removed the legacy CSV export option to streamline export functionality (#418).
Actual local announcement log:
Released 2.5.0: out/2.5.0.md
CHECKPOINT CAPTURE FINISHED# Release 2.5.0
## Version 2.5.0
* Added the ability to configure custom timeout overrides for individual clients (#409).
* Improved webhook reliability by automatically retrying delivery when receiving a 503 (Service Unavailable) response (#412).
* Removed the legacy CSV export option to streamline export functionality (#418).Review the generated wording. The model added “to streamline export functionality,” a purpose that the terse PR fixture does not state. The original response is preserved here. This run demonstrates saved-state continuation and artifact consistency; it does not validate every release-note claim. Read the notes against the source before using them.
The optional diagnostic batch below exercises additional crash points with separate saved runs.
Diagnostic reset without explicit consent. record.py is a separate diagnostic batch. It deletes the example’s own out/ and checkpoints.db, so it refuses to start unless you pass --reset-example-data. Use a disposable copy for this batch. It runs workflow.py as separate processes, with CRASH_AT set to kill the first one at a chosen point, then runs it again.
python record.py --reset-example-data== crash inside a step with a side effect
$ CRASH_AT='announce' python workflow.py --thread r2 --version 2.5.1
finished collect
finished summarize
finished write_file
!! process killed in announce
(exit 1)
$ python workflow.py --thread r2
resuming r2 at announce
finished announce
(exit 0)
== durability exit, then a crash
$ CRASH_AT='after summarize' python workflow.py --thread r4 --version 2.5.3 --durability exit
finished collect
finished summarize
!! process killed in after summarize
(exit 1)
$ python workflow.py --thread r4 --version 2.5.3 --durability exit
finished collect
finished summarize
finished write_file
finished announce
(exit 0)How it works
A checkpoint is a snapshot of the state channels plus the nodes due next, saved under the thread. These are the checkpoints of thread r1, read back from checkpoints.db with get_state_history:
== checkpoints saved for thread r1
step -1 input next=['__start__'] state keys=[]
step 0 loop next=['collect'] state keys=['version']
step 1 loop next=['summarize'] state keys=['prs', 'version']
step 2 loop next=['write_file'] state keys=['notes', 'prs', 'version']
step 3 loop next=['announce'] state keys=['notes', 'path', 'prs', 'version']
step 4 loop next=[] state keys=['notes', 'path', 'prs', 'version']The first process saved the checkpoints up to step 2, whose next is write_file, and died. The second process found that checkpoint, loaded its state, notes included, and ran the last two nodes. Resuming is not replaying: LangGraph doesn’t run collect or summarize again, it starts from their saved output. That is why the state must hold everything a later step needs. A node that read a file or called an API in collect and kept the result only in a local variable would lose it.
Troubleshooting
| Failure | What happens | Fix | Test |
|---|---|---|---|
| A crash inside a step with a side effect | Killed in announce after the post, the run resumed at announce and posted again: Released 2.5.1: out/2.5.1.md appears twice. |
Make the side effect idempotent, as the next lesson does. | test_a_step_interrupted_after_its_side_effect_repeats_it |
| A resume under a new thread ID | The retry with --thread r3-retry found no checkpoint, ran every node, and called the model again. |
Derive the thread ID from the job, such as the release version, not from the attempt. | test_a_new_thread_id_starts_over_and_calls_the_model_again |
durability="exit" and a crash |
Nothing was saved before the kill, so the second process started over. | Use "sync" for runs that must survive a crash; "exit" only for short runs you can repeat. |
test_recorded_runs_resume_and_duplicate_as_described |
| A finished thread resumed | next is empty, and invoke(None) runs nothing. |
Check next before you resume, as run() does. |
test_resuming_a_finished_thread_does_nothing |
The first row is the one to remember. A checkpoint is saved after a node returns, so a node that dies partway runs again from its start. Work that happened inside it, here a post that people already read, happens twice. Checkpoints make a workflow resumable; they don’t make its steps safe to repeat.
Test the resume without a model
test_workflow.py uses a counting scripted model and a temporary SQLite file. It stops a run between steps by breaking out of stream, and inside a step by raising from crash_point, then resumes.
python -m pytest -v test_workflow.py test_record.py test_saved_actual.pytest_workflow.py::test_a_resumed_run_skips_the_steps_already_checkpointed PASSED
test_workflow.py::test_a_step_interrupted_after_its_side_effect_repeats_it PASSED
test_workflow.py::test_a_new_thread_id_starts_over_and_calls_the_model_again PASSED
test_workflow.py::test_resuming_a_finished_thread_does_nothing PASSED
test_workflow.py::test_recorded_runs_resume_and_duplicate_as_described PASSED
============================== 5 passed ===============================The saved output reports 5 passing tests. The current suite also checks explicit reset consent, the dedicated output directory, the serialized Gemini output limit and a single controlled 429 response with retries disabled. These request checks use an offline transport, not a model conversation.
Read the test and recording scripts
"""Offline tests: a scripted model replaces Gemini, and a raised exception stands in for the crash."""
import json
import sqlite3
from pathlib import Path
import pytest
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
from langgraph.checkpoint.sqlite import SqliteSaver
import workflow
RUN = json.loads(Path(__file__).with_name("runs.json").read_text())
class Crash(Exception):
pass
class Counting(GenericFakeChatModel):
calls: int = 0
def invoke(self, *args, **kwargs):
self.calls += 1
return super().invoke(*args, **kwargs)
@pytest.fixture
def setup(tmp_path, monkeypatch):
monkeypatch.setattr(workflow, "OUT", tmp_path / "out")
model = Counting(messages=iter([AIMessage("- Retry webhook delivery on 503 (#412)")] * 5))
saver = SqliteSaver(sqlite3.connect(tmp_path / "checkpoints.db", check_same_thread=False))
return workflow.build_graph(saver, model), model, tmp_path / "out"
def crash_once(monkeypatch, node):
"""Make the next call of `node` raise, as a crash at that point would."""
armed = [True]
def point(name):
if name == node and armed.pop():
raise Crash(node)
monkeypatch.setattr(workflow, "crash_point", lambda name: point(name) if armed else None)
def cfg(thread):
return {"configurable": {"thread_id": thread}}
def test_a_resumed_run_skips_the_steps_already_checkpointed(setup, monkeypatch):
graph, model, out = setup
for update in graph.stream({"version": "2.5.0"}, cfg("t"), stream_mode="updates", durability="sync"):
if "summarize" in update:
break # the process stops here, after summarize was checkpointed
assert graph.get_state(cfg("t")).next == ("write_file",)
graph.invoke(None, cfg("t"), durability="sync")
assert model.calls == 1
assert (out / "2.5.0.md").read_text().startswith("# Release 2.5.0")
def test_a_step_interrupted_after_its_side_effect_repeats_it(setup, monkeypatch):
graph, model, out = setup
crash_once(monkeypatch, "announce")
with pytest.raises(Crash):
graph.invoke({"version": "2.5.1"}, cfg("t"), durability="sync")
graph.invoke(None, cfg("t"), durability="sync")
assert (out / "announcements.log").read_text().count("Released 2.5.1") == 2
def test_a_new_thread_id_starts_over_and_calls_the_model_again(setup, monkeypatch):
graph, model, out = setup
crash_once(monkeypatch, "announce")
with pytest.raises(Crash):
graph.invoke({"version": "2.5.2"}, cfg("t"), durability="sync")
graph.invoke({"version": "2.5.2"}, cfg("t-retry"), durability="sync")
assert model.calls == 2
def test_resuming_a_finished_thread_does_nothing(setup):
graph, model, out = setup
graph.invoke({"version": "2.5.0"}, cfg("t"), durability="sync")
assert graph.get_state(cfg("t")).next == ()
graph.invoke(None, cfg("t"), durability="sync")
assert model.calls == 1
def test_recorded_runs_resume_and_duplicate_as_described():
by_name = {s["name"]: s["processes"] for s in RUN["scenarios"]}
assert by_name["crash between steps, then resume"][1]["output"][0] == "resuming r1 at write_file"
assert RUN["announcements"].count("Released 2.5.1: out/2.5.1.md") == 2
assert "resuming" not in " ".join(by_name["durability exit, then a crash"][1]["output"])
@pytest.mark.parametrize("status", [200, 429])
def test_gemini_serialized_request_has_output_cap_and_no_retry(status, monkeypatch):
"""Exercise the installed SDK serializer and HTTP retry path without a provider."""
import httpx
from langchain_google_genai import ChatGoogleGenerativeAI
requests = []
def handle(request):
assert request.method == "POST"
assert request.url.host == "generativelanguage.googleapis.com"
assert request.url.path == "/v1beta/models/gemini-3.8-flash:generateContent"
body = json.loads(request.content)
assert body["generationConfig"]["maxOutputTokens"] == 1024
assert "tools" not in body
requests.append(body)
if status == 429:
return httpx.Response(429, json={"error": {"code": 429, "status": "RESOURCE_EXHAUSTED",
"message": "Offline retry check"}}, request=request)
return httpx.Response(200, json={"candidates": [{"content": {"role": "model", "parts": [
{"text": "- Offline serializer check only (#412)"}]}, "finishReason": "STOP"}]}, request=request)
def client(**kwargs):
model = ChatGoogleGenerativeAI(**kwargs, api_key="OFFLINE-NON-CREDENTIAL-PLACEHOLDER",
vertexai=False, client_args={"transport": httpx.MockTransport(handle),
"trust_env": False})
assert model.max_retries == 0
assert model.timeout == 30
return model
monkeypatch.setattr(workflow, "MODEL", "gemini-3.8-flash")
monkeypatch.setattr(workflow, "ChatGoogleGenerativeAI", client)
if status == 429:
with pytest.raises(Exception):
workflow.chat_model().invoke("Offline serializer and retry check")
else:
assert workflow.chat_model().invoke("Offline serializer and retry check").text.startswith("- Offline")
assert len(requests) == 1"""Crash and resume the workflow in real processes on Gemini, and save what each process ran."""
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from langgraph.checkpoint.sqlite import SqliteSaver
from workflow import MODEL, build_graph
HERE = Path(__file__).parent
SCENARIOS = {
"crash between steps, then resume": [
("after summarize", ["--thread", "r1", "--version", "2.5.0"]), (None, ["--thread", "r1"])],
"crash inside a step with a side effect": [
("announce", ["--thread", "r2", "--version", "2.5.1"]), (None, ["--thread", "r2"])],
"resume under a new thread id": [
("after summarize", ["--thread", "r3", "--version", "2.5.2"]), (None, ["--thread", "r3-retry", "--version", "2.5.2"])],
"durability exit, then a crash": [
("after summarize", ["--thread", "r4", "--version", "2.5.3", "--durability", "exit"]),
(None, ["--thread", "r4", "--version", "2.5.3", "--durability", "exit"])],
}
def main(reset_example_data: bool = False) -> None:
if not reset_example_data:
raise SystemExit("The diagnostic recorder deletes out/ and checkpoints.db. "
"Use a disposable copy and pass --reset-example-data, or run workflow.py "
"with --work-dir to keep a learner run.")
shutil.rmtree(HERE / "out", ignore_errors=True)
(HERE / "checkpoints.db").unlink(missing_ok=True)
record = {"model": MODEL, "scenarios": []}
for name, processes in SCENARIOS.items():
print(f"== {name}")
runs = []
for crash, args in processes:
env = {k: v for k, v in os.environ.items() if k != "CRASH_AT"} | ({"CRASH_AT": crash} if crash else {})
done = subprocess.run([sys.executable, "workflow.py", *args], cwd=HERE, env=env, capture_output=True, text=True)
shown = (f"CRASH_AT='{crash}' " if crash else "") + " ".join(["python", "workflow.py", *args])
print(f"$ {shown}\n{done.stdout.rstrip()}\n(exit {done.returncode})")
runs.append({"command": shown, "exit": done.returncode, "output": done.stdout.splitlines()})
record["scenarios"].append({"name": name, "processes": runs})
record["announcements"] = (HERE / "out/announcements.log").read_text().splitlines()
record["notes_2_5_0"] = (HERE / "out/2.5.0.md").read_text()
with SqliteSaver.from_conn_string(str(HERE / "checkpoints.db")) as saver: # thread r1, oldest first
history = list(build_graph(saver).get_state_history({"configurable": {"thread_id": "r1"}}))[::-1]
record["r1_checkpoints"] = [{"step": c.metadata["step"], "source": c.metadata["source"], "next": list(c.next),
"keys": sorted(c.values)} for c in history]
print("== out/announcements.log", *record["announcements"], sep="\n")
print("== checkpoints saved for thread r1")
for c in record["r1_checkpoints"]:
print(f" step {c['step']:>2} {c['source']:<5} next={c['next']} state keys={c['keys']}")
(HERE / "runs.json").write_text(json.dumps(record, indent=1) + "\n")
def parse_args(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--reset-example-data", action="store_true",
help="Delete this example's out/ and checkpoints.db before recording all crash scenarios.")
return parser.parse_args(argv)
if __name__ == "__main__":
main(reset_example_data=parse_args().reset_example_data){
"name": "crash inside a step with a side effect",
"processes": [
{
"command": "CRASH_AT='announce' python workflow.py --thread r2 --version 2.5.1",
"exit": 1,
"output": [
"finished collect",
"finished summarize",
"finished write_file",
"!! process killed in announce"
]
},
{
"command": "python workflow.py --thread r2",
"exit": 0,
"output": [
"resuming r2 at announce",
"finished announce"
]
}
]
}Read the explanation
Checkpoints make a run resumable, not its steps repeatable. Make side effects safe to repeat.
What’s next
- Make LangGraph retries idempotent: retry a failed write step and resume a thread without creating duplicates.
References
Official sources checked October 21, 2026, against langgraph 1.2.16 and langgraph-checkpoint-sqlite 3.1.2.
- LangGraph: Persistence: threads, checkpoints,
get_stateandget_state_history. - That a new input on an unfinished thread starts a new run, and that
"async"is the default durability, were checked against the installed langgraph 1.2.16. - LangGraph: Checkpointers:
SqliteSaver,PostgresSaverand theexit,asyncandsyncdurability modes. - LangChain: ChatGoogleGenerativeAI: the Gemini chat model.
- Gemini API: Models: the
gemini-3.8-flashmodel ID.