A run may name another provider for itself (an agent’s
checkpoint_provider, a flow’s
checkpoint_provider); see What a per-run provider means.
Providers
Only
redis and postgres can hold a parked run (one waiting on an async ask): a
paused run must be readable by whichever worker later resumes it.
Retention: two horizons
A waiting thread is one its owner may still continue: a parked run, an open conversation,
any thread nobody marked finished. A finished thread is one its owner marked finished.
- On
redisevery key a write stores carries the waiting retention as its TTL. A read never extends it, so a thread lives the waiting retention after its last write. A parked run’s last write is its park checkpoint. - On
postgresandsqlitethe checkpoint sweep deletes a thread whose newest checkpoint is older than the waiting retention. - A finished thread goes the finished retention after its mark, on every provider, through the checkpoint sweep. The mark records the time the thread becomes due, from the retention in force at the mark: changing the finished value later moves only new marks.
ask must therefore carry a deadline within the waiting retention: a park with no
deadline, or one past the horizon, is refused loudly before anything is parked.
A thread’s own retention
The two values above are every thread’s default and its ceiling. A thread’s owner may declare a shorter retention for the threads it starts — a flow declares one in its document (config.checkpoint_retention, see the flows engine) — never a longer one:
- A declared value above the platform’s value for its horizon is refused loudly, naming both values; it is never shortened silently. A declared finished retention longer than the declared waiting one is refused too.
- A declaration may give only one value. An unset waiting is the platform’s; an unset finished is the platform’s finished retention, capped at the thread’s waiting one.
- On
redisthe declared waiting is the TTL of every key the thread’s writes store. Onpostgresandsqliteit is recorded when a run starts on the thread, and the checkpoint sweep reads it as that thread’s waiting horizon; a later run on the same thread replaces it. - A finished thread is due its declared finished retention after its mark.
- A park on such a thread must fit its declared waiting retention: an async
askwhose deadline runs past it is refused before anything is parked.
LLM_PROVIDER_CHECKPOINT_TTL_MINUTES is gone: its place is taken by the two retention values
above. The flows engine’s FLOW_CHECKPOINT_RETENTION_DAYS and its
POST /api/babelfish/checkpoints/prune route are gone too: the same two values and the
platform’s sweep_checkpoints operation replace them. A deployment that still sets one of
the removed variables is refused at boot and on every settings write, naming it.Who marks a thread finished
The code that minted a thread id owns the thread and marks it finished at its end; every consumer that starts a run on a thread takes it out of the finished set first, so a thread used again never sits there while it is live.- A flow run marks its own session thread (in its checkpoint provider’s ledger) and every
agent session thread it owns — a node whose
session_threadtemplate contains{flow_run_id}— finished at its terminal: success, error, stop or abort, never at a pause. It takes all of them out of the finished set when a run (fresh, restart, resume or step) starts on the session. The owned session threads are marked in the deployment’s ledger (the configured provider and connection): the flow cannot know which provider each agent run on such a thread used. - An agent run with no
thread_idmints its thread and marks it finished when it ends without a park, in its own checkpoint provider’s ledger. A caller-suppliedthread_idis the caller’s: the run takes it out of the finished set when it starts and never marks it. A run that parks leaves its thread to the waiting horizon. - A conversation has no finished moment: a thread stays open for its next turn, so it
leaves on the waiting horizon, or at once when an operator deletes it
(
tai conversations delete-thread). A turn’s agent receives the conversation’s thread as a caller-suppliedthread_id: an agent that checkpoints on it takes it out of the finished set when its run starts (the agent-run rule above); an agent that keeps no checkpoints touches no checkpoint store.
The checkpoint sweep
sweep_checkpoints (POST /api/checkpoints/sweep, tai checkpoints sweep) is the one
operation that deletes checkpoint threads. It is admin-only and destructive; drive it on a
cadence with a schedule or external cron — nothing runs it in the background.
- Every thread marked finished whose due time has passed (its finished retention after the
mark) is deleted, on every provider,
memoryincluded. - On
postgresandsqlite, every thread whose newest checkpoint is older than its waiting retention (the one its owner declared, else the platform’s) is deleted. Onredisthe key TTL does this; onmemorythe thread lives with the process — both reported inskipped.
tai42_checkpoint_sweep_spared_total{horizon,owner} (served on GET /metrics), listed in the
result, and — for a finished thread — kept in the finished set so the next sweep checks it
again. The shipped filters claim:
A filter that fails fails the sweep. Deleting a thread removes every checkpoint and pending
write it holds, however many. A checkpoint a run writes while the thread is being deleted is
deleted again, up to 100 rounds, and then fails the sweep loudly. On the Redis provider one
delete already repeats until the thread is empty, and fails the sweep sooner: at the first
round, counting from its second, that leaves the thread holding no fewer checkpoints and writes
than the round before (
CheckpointDeleteError, naming the thread and what it still holds).
The result:
Scope: the deployment’s store
The sweep covers the configured provider and connection string only — the one store every process shares.What a per-run provider means
- A thread on a per-run
redisstore leaves on its key TTL (the waiting retention), marked or not. - A thread on a per-run
memorystore lives with its process. - A thread on a per-run
postgresorsqlitestore is not swept; its marks land in that store’s own ledger, so a deployment whose configured provider is that store sweeps them. - A flow-owned session thread whose agent ran on a per-run provider is marked in the deployment’s ledger; the sweep’s delete there finds nothing, and the thread leaves on its own store’s waiting horizon.
The store format
The kit records a format generation in eachredis, postgres and sqlite store the
first time a saver is built on it. A store that already holds checkpoints written in another
format — or with no recorded generation — is refused at that first use with
CheckpointStoreFormatError (“reset the checkpoint store before starting this version”): every
run, query or sweep that needs it fails loudly until the store is reset. A deployment that
uses no checkpoint store never builds a saver and never runs the check. The current generation
is 3: a store written by an earlier version is reset before this one starts on it.
What the Redis store keeps
JSON has no form for most Python values a run’s state holds, so the Redis codec stores each such value in a typed envelope and reads it back as the same type:datetime, date, time,
timedelta, timezone (a datetime.timezone, or the fixed offset pydantic attaches to a value it
parses from text, bare or on a value), zoneinfo.ZoneInfo, Decimal, UUID, tuples, sets and frozensets,
dictionaries with non-string keys, bytes nested inside a value, enum members, pydantic models
and dataclasses (their classes importable by module path), the LangChain objects LangChain’s
own loader revives (its messages, documents and prompts) and LangGraph’s own types. A value of
any other type is refused at write, naming its path: so is a NaN or infinite float, which
JSON cannot hold, and a LangChain object its loader will not revive, such as a subclass of a
message class. A stored envelope that cannot be read back fails the read.
A datetime or time keeps its wall-clock value, its fold and its tzinfo when that is a
fixed offset (datetime.timezone, its name kept; the fixed offset pydantic attaches to a value
it parses from text is read back as a datetime.timezone with the same offset and name) or an
IANA zone (zoneinfo.ZoneInfo, kept by its key), so a value read back still follows its zone’s
daylight-saving rules. One carrying any other kind of tzinfo, or a zone read from a file
(which has no key), is refused at write, naming its path; a stored zone key the reading host’s
zone database lacks fails the read.
An exception is stored as a readable record and comes back as a CheckpointedException
carrying it:
kwargs holds the exception’s attributes; an attribute JSON cannot hold is stored as its
repr() and named in repr_kwargs, and an attribute holding another exception becomes its own record.
cause is the record of the exception it was raised from.
