fix(checkpoints): run conservative retention after completion (#6051)

* fix(checkpoints): wire conservative retention after runs

* fix(checkpoints): fence retention before checkpoint reads

* fix(checkpoints): address retention review feedback

* fix(checkpoints): retry delta reads after retention race

* fix(checkpoints): bound admission retention to the root tail

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Stella-xixi 2026-10-20 22:23:47 +08:00 committed by GitHub
parent eb7c342fb1
commit 5cef1a11ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 884 additions and 79 deletions

View File

@ -540,6 +540,13 @@ different cache settings. Redis thread cleanup treats a configured `key_prefix`
as literal text, including glob metacharacters, so deletion stays within that
namespace and thread.
Gateway checkpoint cleanup runs when the next run wins durable same-thread
admission, before its first checkpoint read. It checks only the root head and
its parent, removes at most one metadata-only duration leaf without pending
writes, and preserves explicit checkpoint selections and delta replay state.
See [checkpoint operations](https://deerflow.tech/docs/harness/checkpoints/operations)
for the retention constraints.
> [!TIP]
> On Linux, if Docker-based commands fail with `permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock`, add your user to the `docker` group and re-login before retrying. See [CONTRIBUTING.md](CONTRIBUTING.md#linux-docker-daemon-permission-denied) for the full fix.

View File

@ -4,24 +4,19 @@ Reject external `sandbox`, `thread_data`, `viewed_images`, `goal_outcome`. Non-n
Studio retains sanitized creation metadata.
Capability Center's `business` adapter validates bundled credentials and
creates an MCP connection. The MCP API permits only the isolated launcher and
credential environment keys generated by `deerflow.capabilities.business`;
reject arbitrary Python commands and manifest policy bypasses. Keep credentials
out of tool schemas; preserve admin checks, masked edits, atomic writes, and
MCP cache reloads.
Capability Center's `business` adapter validates bundled credentials and creates
an MCP connection. The MCP API permits only its isolated launcher and credential
environment keys; reject arbitrary commands and manifest-policy bypasses. Keep
credentials out of tool schemas and preserve admin checks, masked atomic edits,
and MCP cache reloads.
Memory shutdown resolves hot-reloaded config and the backend, flushes, then
closes as one `await_drained` operation. Keep config resolution inside the
best-effort error handler and off the event loop so malformed config edits do
not abort runtime teardown. Cancellation waits for the owned workers before
propagating; backend `close()` overrides must be quick or internally bounded
because close has no host timeout. Budget resolution and close in the pod grace
period in addition to the configured flush timeout and other shutdown hooks.
Extension-service teardown follows the same ownership rule: `stop_services()`
is drained across host cancellation before later runtime resources unwind; each
service `stop()` remains bounded by its 30-second `asyncio.timeout`, so include
that bound in pod grace-period budgeting.
Memory shutdown resolves hot-reloaded config/backend, flushes, then closes as
one `await_drained` operation. Resolve config inside the best-effort handler and
off-loop so malformed edits cannot abort teardown. Cancellation waits for owned
workers; `close()` must be quick or internally bounded because the host sets no
timeout. Budget it, the flush timeout, and other hooks in the pod grace period.
Likewise drain extension `stop_services()` across cancellation before later
resources unwind; each service `stop()` has a 30-second `asyncio.timeout`.
Routers with persistent writes drain through
`app.gateway.persistent_writes.run_drained_write`.
@ -31,6 +26,11 @@ its run drain and extension-service teardown can still make MCP calls during
earlier shutdown hooks. Keep the pool close bounded and failure-isolated, but
do not move it ahead of those session producers.
Retention holds durable thread admission and the checkpoint lock before
checkpoint reads. Inspect root head/parent and leaf writes only; protect selectors,
require equal channel versions and invalidate caches before deletion. No scans,
stats or blob GC; failures stay non-fatal.
`conversation_access.py` grants the opt-in read-only tool only for explicit
`conversation_references` with effective `runs:read`; never infer grants from
messages or checkpoints. Pass the callback through `RunContext`, not serialized

View File

@ -23,24 +23,26 @@ by a deleted checkpoint in the same step (contract "deletion mechanics").
Postgres ``checkpoint_blobs`` rows are keyed by ``version`` = the id of the
checkpoint that wrote the blob, so the same join keys clean them.
The service ships **without a production trigger**: where retention is
invoked from (post-run hook vs scheduler vs explicit admin action) is a
maintainer decision that lands with the contract itself. Measurement-first:
reports carry before/after per-thread stats in the same normalized shape as
The Gateway invokes the conservative production policy after the next run has
won durable same-thread admission but before that worker reads a checkpoint.
It removes at most the trailing duration/history-cache leaf; the sibling-branch
shape remains opt-in. The admission path reads only the root head and parent,
checks that head for writes, and leaves shared blobs in place. The exhaustive
maintenance policy alone collects before/after per-thread stats in the same
normalized shape as
``scripts/benchmark/checkpoint/bench_channels.py``.
History fast-path interaction: the trailing duration-only leaf is also the
carrier of the run-history metadata cache (``run_durations`` /
``run_message_ids``) that ``get_thread_history`` reads from the latest
checkpoint, and the parent it clones does not carry that map. Deleting the
leaf makes the next history read fall back to store scans and re-persist a
fresh leaf, so the wiring PR must sequence retention away from history reads
or adopt a policy that spares cache-carrying leaves — see the contract doc,
"History fast-path interaction (wiring requirement)".
``run_message_ids``). Retaining it until the next admission lets repeated
history reads keep their fast path; pruning immediately before checkpoint
selection prevents the next run from making a regenerated cache leaf a
permanent ancestor.
"""
from __future__ import annotations
import logging
from collections import defaultdict
from contextlib import AbstractAsyncContextManager, nullcontext
from dataclasses import dataclass, field
@ -55,7 +57,15 @@ from app.gateway.checkpoint_lineage import (
is_duration_only_checkpoint,
)
__all__ = ["RetentionPolicy", "RetentionReport", "enforce_thread_retention"]
__all__ = [
"RetentionPolicy",
"RetentionReport",
"enforce_completed_run_retention",
"enforce_thread_retention",
"enforce_trailing_duration_retention",
]
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
@ -150,7 +160,13 @@ def _mark_duration_leaves_without_the_marker(nodes: dict[tuple[str, str], _Node]
node.duration_only = True
def _ensure_supported_saver(saver: BaseCheckpointSaver) -> None:
def _storage_saver(saver: BaseCheckpointSaver) -> BaseCheckpointSaver:
"""Resolve a wrapper through its public source-saver contract."""
storage = getattr(saver, "source_saver", saver)
return storage if isinstance(storage, BaseCheckpointSaver) else saver
def _ensure_supported_saver(saver: BaseCheckpointSaver) -> BaseCheckpointSaver:
"""Fail fast before any row is read or written on an untested saver.
The per-backend helpers below model exactly three storage layouts
@ -163,18 +179,12 @@ def _ensure_supported_saver(saver: BaseCheckpointSaver) -> None:
``NotImplementedError`` beats a partial deletion and a confusing
traceback.
"""
if isinstance(saver, (InMemorySaver, AsyncSqliteSaver)):
return
async_postgres_saver: type | None = None
try:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
async_postgres_saver = AsyncPostgresSaver
except Exception:
pass
if async_postgres_saver is not None and isinstance(saver, async_postgres_saver):
return
raise NotImplementedError(f"checkpoint retention supports InMemorySaver, AsyncSqliteSaver and AsyncPostgresSaver, got {type(saver).__module__}.{type(saver).__name__}")
storage = _storage_saver(saver)
if isinstance(storage, (InMemorySaver, AsyncSqliteSaver)):
return storage
if callable(getattr(storage, "_cursor", None)):
return storage
raise NotImplementedError(f"checkpoint retention requires a supported storage saver, got {type(storage).__module__}.{type(storage).__name__}")
async def _thread_storage_stats(saver: Any, thread_id: str) -> dict[str, int]:
@ -242,6 +252,25 @@ async def _thread_storage_stats(saver: Any, thread_id: str) -> dict[str, int]:
return stats
async def _checkpoint_has_writes(saver: Any, key: tuple[str, str], thread_id: str) -> bool:
"""Check one checkpoint's pending writes without scanning its thread."""
ns, cp_id = key
if isinstance(saver, InMemorySaver):
return bool(saver.writes.get((thread_id, ns, cp_id)))
if isinstance(saver, AsyncSqliteSaver):
async with saver.conn.execute(
"SELECT 1 FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? LIMIT 1",
(thread_id, ns, cp_id),
) as cursor:
return await cursor.fetchone() is not None
async with saver._cursor() as cursor:
await cursor.execute(
"SELECT 1 FROM checkpoint_writes WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s LIMIT 1",
(thread_id, ns, cp_id),
)
return await cursor.fetchone() is not None
async def _checkpoint_ids_with_writes(saver: Any, thread_id: str) -> set[tuple[str, str]]:
"""(checkpoint_ns, checkpoint_id) pairs that still own writes rows.
@ -378,7 +407,7 @@ async def enforce_thread_retention(
before writing, so a post-run-hook call site must invoke retention *after*
releasing it, not from inside the held section.
"""
_ensure_supported_saver(saver)
storage_saver = _ensure_supported_saver(saver)
effective = policy or RetentionPolicy()
if effective.max_delete_per_run is not None and effective.max_delete_per_run < 0:
# Fail closed *before* any store read. ``deletable[:cap]`` with a
@ -390,10 +419,10 @@ async def enforce_thread_retention(
lock: AbstractAsyncContextManager[None] = thread_lock if thread_lock is not None else nullcontext()
async with lock:
if collect_stats:
report.stats_before = await _thread_storage_stats(saver, thread_id)
report.stats_before = await _thread_storage_stats(storage_saver, thread_id)
nodes: dict[tuple[str, str], _Node] = {}
async for tuple_ in saver.alist(_thread_config(thread_id), limit=None):
async for tuple_ in storage_saver.alist(_thread_config(thread_id), limit=None):
configurable = checkpoint_configurable(tuple_)
cp_id = configurable.get("checkpoint_id")
if not cp_id:
@ -476,7 +505,7 @@ async def enforce_thread_retention(
guarded: set[tuple[str, str]] = set()
if effective.strict_pending_write_guard:
guarded = await _checkpoint_ids_with_writes(saver, thread_id)
guarded = await _checkpoint_ids_with_writes(storage_saver, thread_id)
deletable: list[tuple[str, str]] = []
for key, node in nodes.items():
@ -511,13 +540,129 @@ async def enforce_thread_retention(
if key not in deleted_keys:
survivor_versions.update(node.versions)
# Invalidate before touching source-of-truth rows. If cache deletion
# fails, fail closed with every checkpoint intact; doing this after the
# first DELETE could leave a stale wrapper entry for a missing node.
invalidate_cache = getattr(saver, "ainvalidate_history_cache", None)
if deletable and callable(invalidate_cache):
await invalidate_cache(thread_id)
for key in deletable:
await _delete_checkpoint_rows(saver, thread_id, key)
await _delete_checkpoint_rows(storage_saver, thread_id, key)
report.deleted_checkpoint_ids.append(key[1])
if deletable:
await _delete_unreachable_blobs(saver, thread_id, survivor_versions)
await _delete_unreachable_blobs(storage_saver, thread_id, survivor_versions)
if collect_stats:
report.stats_after = await _thread_storage_stats(saver, thread_id)
report.stats_after = await _thread_storage_stats(storage_saver, thread_id)
return report
async def enforce_trailing_duration_retention(
saver: BaseCheckpointSaver,
thread_id: str,
*,
protect_checkpoint_ids: frozenset[str] = frozenset(),
thread_lock: AbstractAsyncContextManager[None] | None = None,
) -> RetentionReport:
"""Prune at most the current root duration leaf with bounded storage work.
Read the head and its parent directly, then probe only the head's pending
writes. Identical channel-version maps prove that every blob referenced by
the leaf remains reachable through its parent, so no history scan, thread
statistics or blob GC is needed. Explicit admission selectors are protected.
Invalidate wrapper caches before deleting the source row; invalidation
failure must leave the row intact for delta reads.
As with the exhaustive service, callers must exclude same-thread mutation
for this entire operation. Gateway supplies durable admission and the local
checkpoint lock before the worker's first checkpoint read.
"""
storage = _ensure_supported_saver(saver)
report = RetentionReport(thread_id=thread_id)
lock: AbstractAsyncContextManager[None] = thread_lock if thread_lock is not None else nullcontext()
async with lock:
latest = await storage.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}})
if latest is None:
return report
latest_config = checkpoint_configurable(latest)
latest_id = latest_config.get("checkpoint_id")
if not latest_id or latest_config.get("thread_id") != thread_id or latest_config.get("checkpoint_ns") or latest_id in protect_checkpoint_ids:
return report
parent_config = getattr(latest, "parent_config", None)
parent_values = parent_config.get("configurable") if isinstance(parent_config, dict) else None
if not isinstance(parent_values, dict):
return report
parent_id = parent_values.get("checkpoint_id")
if not parent_id or parent_id == latest_id or parent_values.get("thread_id") != thread_id or parent_values.get("checkpoint_ns"):
return report
parent = await storage.aget_tuple(parent_config)
if parent is None or checkpoint_configurable(parent).get("checkpoint_id") != parent_id:
return report
latest_versions = latest.checkpoint.get("channel_versions")
parent_versions = parent.checkpoint.get("channel_versions")
if not isinstance(latest_versions, dict) or latest_versions != parent_versions:
return report
metadata = latest.metadata or {}
run_durations = metadata.get("run_durations")
latest_node = _Node(
ns="",
cp_id=latest_id,
parent_ns="",
parent_id=parent_id,
duration_only=is_duration_only_checkpoint(latest),
metadata_source=metadata.get("source"),
carries_run_durations=isinstance(run_durations, dict) and bool(run_durations),
versions=frozenset(latest_versions.values()),
)
parent_node = _Node(ns="", cp_id=parent_id, parent_ns=None, parent_id=None, duration_only=False, versions=latest_node.versions)
_mark_duration_leaves_without_the_marker({("", latest_id): latest_node, ("", parent_id): parent_node})
if not latest_node.duration_only or await _checkpoint_has_writes(storage, ("", latest_id), thread_id):
return report
invalidate_cache = getattr(saver, "ainvalidate_history_cache", None)
if callable(invalidate_cache):
await invalidate_cache(thread_id)
await _delete_checkpoint_rows(storage, thread_id, ("", latest_id))
report.protected_head_ids = {"": parent_id}
report.deleted_checkpoint_ids.append(latest_id)
return report
async def enforce_completed_run_retention(
*,
checkpointer: BaseCheckpointSaver,
thread_id: str,
protect_checkpoint_ids: frozenset[str] = frozenset(),
) -> RetentionReport:
"""Run the conservative policy inside the next run's durable admission.
Successful runs append one metadata-only duration checkpoint. If it is
left in place, the next run forks from it and turns that otherwise
disposable leaf into a protected ancestor forever. The run worker calls
this hook only after its active durable row excludes all same-thread run,
branch and checkpoint-write admissions, and before it reads a checkpoint.
The process-local checkpoint lock additionally serializes the local saver.
Only the contract-proven trailing duration leaf is eligible. Sibling
branches and resumable checkpoints remain protected. Storage work is bounded
to the root head, its parent and one pending-write probe, independently of
thread history length.
"""
from deerflow.runtime.runs.worker import _checkpoint_thread_lock
report = await enforce_trailing_duration_retention(
checkpointer,
thread_id,
protect_checkpoint_ids=protect_checkpoint_ids,
thread_lock=_checkpoint_thread_lock(thread_id),
)
if report.deleted_checkpoint_ids:
logger.info(
"Checkpoint retention removed trailing metadata checkpoints for thread %s: %s",
thread_id,
report.deleted_checkpoint_ids,
)
return report

View File

@ -1185,6 +1185,53 @@ def get_subagent_batch_service(request: Request):
return val
def _run_admission_hook(*, checkpointer: Any):
"""Prune metadata leaves inside the run's durable admission fence."""
async def handle(record: Any, config: dict[str, Any]) -> None:
try:
from app.gateway.checkpoint_retention import enforce_completed_run_retention
await enforce_completed_run_retention(
checkpointer=checkpointer,
thread_id=record.thread_id,
protect_checkpoint_ids=_selected_checkpoint_ids(config),
)
except Exception:
logger.warning(
"Checkpoint retention failed for thread %s before run %s (non-fatal)",
record.thread_id,
record.run_id,
exc_info=True,
)
return handle
def _selected_checkpoint_ids(config: dict[str, Any]) -> frozenset[str]:
"""Collect checkpoint selectors already validated for an admitted run."""
configurable = config.get("configurable", {})
selected: set[str] = set()
checkpoint_id = configurable.get("checkpoint_id")
if isinstance(checkpoint_id, str) and checkpoint_id:
selected.add(checkpoint_id)
checkpoint_map = configurable.get("checkpoint_map")
if isinstance(checkpoint_map, dict):
selected.update(value for value in checkpoint_map.values() if isinstance(value, str) and value)
return frozenset(selected)
def _run_completion_hook(*, scheduled_task_service: Any | None):
"""Notify the scheduler after ordinary run finalization."""
async def handle(record: Any) -> None:
if scheduled_task_service is not None:
await scheduled_task_service.handle_run_completion(record)
return handle
def get_run_context(request: Request) -> RunContext:
"""Build a :class:`RunContext` from ``app.state`` singletons.
@ -1198,9 +1245,11 @@ def get_run_context(request: Request) -> RunContext:
host = getattr(request.app.state, "agent_runs_host", None)
# Internal/channel and PAT runs deliberately receive no retained delegation.
agent_runs = host.bind(request) if host is not None else None
checkpointer = get_checkpointer(request)
scheduled_task_service = getattr(request.app.state, "scheduled_task_service", None)
return RunContext(
agent_runs=agent_runs,
checkpointer=get_checkpointer(request),
checkpointer=checkpointer,
store=get_store(request),
event_store=get_run_event_store(request),
run_events_config=getattr(request.app.state, "run_events_config", None),
@ -1210,7 +1259,10 @@ def get_run_context(request: Request) -> RunContext:
mcp_task_repo=getattr(request.app.state, "mcp_task_repo", None),
app_config=get_config(),
extensions=getattr(request.app.state, "extensions", None),
on_run_completed=getattr(request.app.state, "scheduled_task_service", None).handle_run_completion if getattr(request.app.state, "scheduled_task_service", None) is not None else None,
on_run_admitted=_run_admission_hook(checkpointer=checkpointer),
on_run_completed=_run_completion_hook(
scheduled_task_service=scheduled_task_service,
),
)

View File

@ -73,9 +73,9 @@ resume, (c) branch from an older visible turn, and (d) orphan row counts.
## Deletion mechanics
- Deletion must cover the backend's tables jointly and account for orphans, and
blob reachability must be computed from the **surviving checkpoints in a
whole-thread pass**: after deleting a checkpoint row, a `checkpoint_blobs` /
- Deletion must cover the backend's tables jointly and account for orphans.
General retention computes blob reachability from the **surviving checkpoints
in a whole-thread pass**: after deleting a checkpoint row, a `checkpoint_blobs` /
`checkpoint_writes` row is an orphan only if *no surviving checkpoint*
references it. The shared-version case is not hypothetical — the real
duration-only checkpoint is a copy of the head checkpoint dict
@ -89,6 +89,11 @@ resume, (c) branch from an older visible turn, and (d) orphan row counts.
payload-free: it materializes the parent's values under
`{"writes": {"runtime_run_duration": {...}}, "source": "update", "step":
...}` metadata, which is what makes reclaiming it worthwhile.)
- Bounded admission cleanup instead proves that the root duration leaf and
its addressable parent have identical `channel_versions` maps. It deletes
only that leaf's checkpoint/writes rows and leaves all blobs intact: the
parent still references every version the leaf did. No whole-thread blob
scan or GC is needed for this shape.
- Failure semantics: if a proposed deletion cannot be proven safe against the
protected set, it must not ship. Partial deletion that leaves a dangling
`parent_config` converts a cleanup into a thread-level outage (branch and
@ -116,21 +121,26 @@ duration-only leaf — which the next retention pass deletes again. Net effect
without sequencing: the reclaimed row comes straight back, plus recurring
store scans and an extra write per read.
The wiring PR that introduces the production trigger must therefore either:
Gateway runs retention only when the next run has won durable same-thread
admission, before that worker's first checkpoint read, and under the local
checkpoint lock. The cache leaf remains available while the thread is idle;
cleanup does not run from the completion callback. The admitted run's
`checkpoint_id` and every `checkpoint_map` selector are explicitly protected.
1. **Sequence retention away from history reads** — e.g. run retention on a
schedule whose next pass re-reclaims the re-created leaf, or run it when
the thread is not being read; or
2. **Adopt a policy that spares cache-carrying leaves** — e.g. a
`RetentionPolicy` flag that keeps any trailing duration-only leaf whose
metadata still carries `run_durations` / `run_message_ids` (same spirit
as the strict pending-writes guard), at the cost of not reclaiming that
leaf's rows.
The production `enforce_completed_run_retention` entrypoint delegates to
`enforce_trailing_duration_retention`: at most two checkpoint lookups (the
root head and its parent), one checkpoint-scoped pending-write existence
probe, and one leaf deletion. It performs no `alist`, whole-thread writes
audit, statistics or blob GC; work does not grow with retained history length.
The exhaustive `enforce_thread_retention` engine remains separate for
maintenance and contract measurements. A missing parent, changed channel
versions, or writes on the candidate makes admission cleanup a no-op.
Without either, E1 pruning and history reads churn against each other. This
decision belongs to the wiring PR, not to the storage-level service: the
service cannot tell a cache-carrying leaf from a payload-free one on the
alist path without re-implementing the writer's merge semantics.
Wrapper histories are invalidated through the public cache lifecycle API
before source deletion; an invalidation failure leaves the checkpoint intact.
Cleanup failures are non-fatal to admission. Unpinned delta state reads retry
once if their resolved target disappeared during hydration; explicit
historical reads never silently move to a different checkpoint.
## Item 4 note (large tool results)

View File

@ -50,7 +50,7 @@ Checkpointer storage runs in one of two channel modes, selected by `checkpoint_c
**Compatibility is asymmetric and fail-closed.** Every checkpoint written in delta mode carries metadata marker `deerflow_checkpoint_channel_mode: "delta"` (injected via `inject_checkpoint_mode`; absence of marker = full, so pre-feature checkpoints need no migration). Before any state read/write, `ensure_checkpoint_mode_compatible` rejects a full-mode process opening a delta thread with `CheckpointModeMismatchError` (surfaced as HTTP 409 with the cause and thread id by the threads router; `CheckpointModeReconfigurationError` maps to 503) — a full-mode raw read of a delta blob would silently return empty/partial `messages`. The reverse direction is allowed: delta-mode processes read full checkpoints transparently (old full checkpoints seed the delta channel), so full → delta is the smooth migration path; delta → full requires materializing/converting the data first. Detection also honors upstream's `counters_since_delta_snapshot.messages` metadata, and an explicit config marker takes precedence over any ambient context value.
**Never bypass `CheckpointStateAccessor` (`runtime/checkpoint_state.py`) for thread-state access.** It is the single choke point binding graph + checkpointer + mode: it injects the mode marker into configs, runs the compatibility check before every `get`/`update`/`history`, and returns materialized state (delta checkpoints lack `channel_values.messages` — raw `get_tuple` reads see a sentinel). For metadata-only reads, use `get_metadata` / `aget_metadata` to retain the mode gate without materialization. Thread-owned reads must use `build_thread_checkpoint_state_accessor` so the recorded assistant schema materializes every channel. Sync checkpoint mutations must drain off-thread commits before cancellation propagates; sync `get_tuple` reads remain cancellable. `history(limit)` semantics: `0` means zero items (explicit empty), `None` means unlimited — do not pass `limit=0` through to `graph.get_state_history`. Assistant metadata lookup is fail-closed for mutation accessors so a store outage cannot silently select the default schema and discard extension channels. In `full` mode the read path degrades to a raw checkpointer read (`_RawCheckpointReadAccessor`) when the agent factory cannot build the graph (bad model config, MCP outage) — full checkpoints carry complete `channel_values`, so reads don't need the graph; degraded snapshots take `created_at` from the standard checkpoint `ts` field, falling back to metadata only for compatibility. The delta gate still applies on the degraded path; `next`/`tasks` degrade to empty and thread status falls back to the stored status because task presence is not derivable, while delta mode has no fallback (materialization needs the channel table).
**Never bypass `CheckpointStateAccessor` (`runtime/checkpoint_state.py`) for thread-state access.** It is the single choke point binding graph + checkpointer + mode: it injects the mode marker into configs, runs the compatibility check before every `get`/`update`/`history`, and returns materialized state (delta checkpoints lack `channel_values.messages` — raw `get_tuple` reads see a sentinel). An async unpinned delta read verifies that its resolved checkpoint still exists after materialization and retries once from the new head when retention removed that target mid-read; explicit historical selections never silently move. For metadata-only reads, use `get_metadata` / `aget_metadata` to retain the mode gate without materialization. Thread-owned reads must use `build_thread_checkpoint_state_accessor` so the recorded assistant schema materializes every channel. Sync checkpoint mutations must drain off-thread commits before cancellation propagates; sync `get_tuple` reads remain cancellable. `history(limit)` semantics: `0` means zero items (explicit empty), `None` means unlimited — do not pass `limit=0` through to `graph.get_state_history`. Assistant metadata lookup is fail-closed for mutation accessors so a store outage cannot silently select the default schema and discard extension channels. In `full` mode the read path degrades to a raw checkpointer read (`_RawCheckpointReadAccessor`) when the agent factory cannot build the graph (bad model config, MCP outage) — full checkpoints carry complete `channel_values`, so reads don't need the graph; degraded snapshots take `created_at` from the standard checkpoint `ts` field, falling back to metadata only for compatibility. The delta gate still applies on the degraded path; `next`/`tasks` degrade to empty and thread status falls back to the stored status because task presence is not derivable, while delta mode has no fallback (materialization needs the channel table).
**Replay checkpoint lookup prefers lineage and degrades only for an explicitly missing legacy parent link.** Branch and regenerate paths first walk `parent_config`, which prevents a global chronological scan from selecting a sibling created by regeneration. `CheckpointParentMissingError` alone enables the bounded newest-first history fallback in `app/gateway/checkpoint_lineage.py`; cycles, dangling/non-addressable parents, target mismatches, and depth exhaustion raise `CheckpointLineageIntegrityError` and fail closed instead of selecting a sibling. The compatibility scans request 400 raw checkpoints so up to 200 duration-only entries do not consume the effective branch-history budget; the fallback scans oldest-to-newest internally, skips duration-only checkpoints, and accepts only checkpoints with an addressable id as the replay base. A source history with no discoverable pre-user checkpoint preserves the historical single-checkpoint branch behavior instead of rejecting the branch; regeneration remains unavailable for that inherited response. Existing single-checkpoint branches are not mutated by regenerate preparation, and no raw checkpoint tuple is copied across threads because delta state depends on ancestry and pending writes. Regenerate source-run lookup uses the current thread's exact event, then the server-stamped `run_id` on the copied human message, then verified RunManager content matching; it does not read parent-thread events. When an interrupted response was streamed but never checkpointed, regeneration accepts only the latest visible human message's server-stamped `run_id` after verifying that it belongs to the same thread and still has `interrupted` status. Storage or checkpoint-mode failures are not treated as a missing base and still fail closed.

View File

@ -148,6 +148,20 @@ class CheckpointStateAccessor:
prepared = self._prepare_config(config)
snapshot = await self.graph.aget_state(prepared)
raise_if_snapshot_incompatible(snapshot, self.mode)
# Delta materialization may perform a second saver read after LangGraph
# has resolved the latest checkpoint. Retention can remove a trailing
# metadata-only leaf in that interval. CachedHistorySaver then cannot
# walk the vanished target's parent chain and the first materialization
# can contain an empty messages channel. For an unpinned latest-state
# read, verify that the resolved target still exists and retry once from
# the new head when it does not. Explicit historical selections remain
# fail-closed instead of silently moving to another checkpoint.
if self.mode == "delta" and not prepared["configurable"].get("checkpoint_id"):
snapshot_config = getattr(snapshot, "config", None)
checkpoint_id = (snapshot_config or {}).get("configurable", {}).get("checkpoint_id")
if checkpoint_id and await self.checkpointer.aget_tuple(snapshot_config) is None:
snapshot = await self.graph.aget_state(prepared)
raise_if_snapshot_incompatible(snapshot, self.mode)
return snapshot
def get_tuple(self, config: dict[str, Any]) -> Any | None:

View File

@ -81,6 +81,11 @@ class CachedHistorySaver(BaseCheckpointSaver):
self._compose_hits = 0
self._full_walks = 0
@property
def source_saver(self) -> BaseCheckpointSaver:
"""Return the saver that owns the authoritative checkpoint rows."""
return self._inner
def __getattr__(self, name: str) -> Any:
# Safety net for saver-specific extras (e.g. AsyncSqliteSaver.setup).
# Base-class methods are explicitly delegated below, so this only
@ -336,6 +341,16 @@ class CachedHistorySaver(BaseCheckpointSaver):
if delete is not None:
await delete(self._key_prefix, thread_id)
async def ainvalidate_history_cache(self, thread_id: str) -> None:
"""Drop cached delta histories after an external chain mutation.
Storage-level retention deliberately operates on ``_inner`` because the
wrapper has no row-deletion API. Keeping invalidation on the wrapper
preserves its lifecycle contract without exposing cache internals to
the Gateway.
"""
await self._apurge_thread(thread_id)
async def acopy_thread(self, source_thread_id: str, target_thread_id: str) -> None:
await self._inner.acopy_thread(source_thread_id, target_thread_id)

View File

@ -675,6 +675,9 @@ class RunContext:
# Delta snapshot cadence frozen at startup; ``None`` means "not frozen in
# this process" (embedded/tests) and resolves to the config default.
checkpoint_snapshot_frequency: int | None = None
# Called after durable same-thread admission is active and before the first
# checkpoint read. Failures are isolated from the run outcome.
on_run_admitted: Any | None = field(default=None)
on_run_completed: Any | None = field(default=None)
# The host binds this capability to one run's authenticated reader and references.
conversation_reader: Any | None = field(default=None)
@ -1150,6 +1153,12 @@ async def run_agent(
return
started = True
if ctx.on_run_admitted is not None:
try:
await ctx.on_run_admitted(record, config)
except Exception:
logger.warning("Run admission hook failed for %s (non-fatal)", run_id, exc_info=True)
task_id = lead_task_id(run_id)
if extensions.needs_task_store:
task_store = ExtensionData(task_id)

View File

@ -14,14 +14,17 @@ from __future__ import annotations
import asyncio
import copy
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from types import SimpleNamespace
from typing import Annotated, Any, TypedDict
from uuid import uuid4
import pytest
from langchain_core.messages import AnyMessage, HumanMessage
from langgraph.channels import DeltaChannel
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from langgraph.graph import END, START, StateGraph
@ -32,15 +35,28 @@ from app.gateway.checkpoint_lineage import find_checkpoint_before_message
from app.gateway.checkpoint_retention import (
RetentionPolicy,
_row_field,
enforce_completed_run_retention,
enforce_thread_retention,
)
from deerflow.runtime.runs.worker import _new_checkpoint_marker, persist_run_durations
from deerflow.agents.thread_state import merge_message_writes
from deerflow.runtime.checkpoint_cache.base import make_history_key
from deerflow.runtime.checkpoint_cache.memory import MemoryCheckpointHistoryCache
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
from deerflow.runtime.checkpointer.cached_saver import CachedHistorySaver
from deerflow.runtime.runs.worker import _new_checkpoint_marker, persist_run_durations, persist_run_history_metadata
class FullState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
class DeltaState(TypedDict):
messages: Annotated[
list[AnyMessage],
DeltaChannel(merge_message_writes, snapshot_frequency=2),
]
def _thread_id() -> str:
return f"retention-service-{uuid4().hex}"
@ -604,6 +620,451 @@ async def test_empty_thread_reports_the_same_before_and_after_stats(saver_env: _
assert without_stats.stats_after == {}
@pytest.mark.anyio
@pytest.mark.parametrize("state_schema", [FullState, DeltaState], ids=["full", "delta"])
async def test_completed_run_retention_removes_only_the_trailing_duration_leaf(
saver_env: _SaverEnv,
state_schema: Any,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The production hook removes the metadata leaf before a later run can
make it a permanent ancestor, without touching resumable checkpoints."""
graph = _build_graph(state_schema, saver_env.saver)
thread_id = _thread_id()
await graph.ainvoke({"messages": [HumanMessage(content="hello")]}, _config(thread_id))
resumable_head = await saver_env.saver.aget_tuple(_config(thread_id))
assert resumable_head is not None
resumable_head_id = resumable_head.checkpoint["id"]
assert await persist_run_durations(
checkpointer=saver_env.saver,
thread_id=thread_id,
durations={"run-1": 3},
)
duration_head = await saver_env.saver.aget_tuple(_config(thread_id))
assert duration_head is not None
duration_head_id = duration_head.checkpoint["id"]
assert duration_head_id != resumable_head_id
with caplog.at_level(logging.INFO, logger="app.gateway.checkpoint_retention"):
report = await enforce_completed_run_retention(
checkpointer=saver_env.saver,
thread_id=thread_id,
)
assert report.deleted_checkpoint_ids == [duration_head_id]
assert report.stats_before == {}
assert report.stats_after == {}
resumed = await saver_env.saver.aget_tuple(_config(thread_id))
assert resumed is not None
assert resumed.checkpoint["id"] == resumable_head_id
materialized = await graph.aget_state(_config(thread_id))
assert materialized.values["messages"][-1].content == "hello"
info_records = [record for record in caplog.records if record.levelno == logging.INFO]
assert len(info_records) == 1
assert thread_id in info_records[0].message
assert duration_head_id in info_records[0].message
await enforce_completed_run_retention(checkpointer=saver_env.saver, thread_id=thread_id)
assert len([record for record in caplog.records if record.levelno == logging.INFO]) == 1
@pytest.mark.anyio
async def test_completed_run_retention_supports_cached_history_saver_and_invalidates_cache(
saver_env: _SaverEnv,
) -> None:
"""The delta wrapper prunes through its source-of-truth saver and purges
cached histories whose checkpoint keys may have just been removed."""
cache = MemoryCheckpointHistoryCache(max_entries=16)
wrapped = CachedHistorySaver(saver_env.saver, cache, key_prefix="retention-test")
graph = _build_graph(DeltaState, wrapped)
thread_id = _thread_id()
await graph.ainvoke({"messages": [HumanMessage(content="hello")]}, _config(thread_id))
assert await persist_run_history_metadata(
checkpointer=wrapped,
thread_id=thread_id,
durations={"run-1": 3},
message_run_ids={"message-1": "run-1"},
)
duration_head = await wrapped.aget_tuple(_config(thread_id))
assert duration_head is not None
duration_head_id = duration_head.checkpoint["id"]
cache_key = make_history_key("retention-test", thread_id, "", duration_head_id, "messages")
await cache.aset_many({cache_key: {"writes": []}})
assert cache.stats().entries == 1
report = await enforce_completed_run_retention(
checkpointer=wrapped,
thread_id=thread_id,
)
assert report.deleted_checkpoint_ids == [duration_head_id]
assert cache.stats().entries == 0
assert await saver_env.saver.aget_tuple(_config_thread(thread_id, duration_head_id)) is None
@pytest.mark.anyio
async def test_sqlite_delta_state_read_retries_when_retention_deletes_resolved_leaf(
tmp_path: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A latest-state read must not lose history when retention removes the
duration leaf after LangGraph resolves it but before delta hydration."""
async with _open_sqlite(tmp_path / "retention-read-race.sqlite") as inner:
wrapped = CachedHistorySaver(
inner,
MemoryCheckpointHistoryCache(max_entries=16),
key_prefix="retention-read-race",
)
graph = _build_graph(DeltaState, wrapped)
accessor = CheckpointStateAccessor.bind(graph, wrapped, mode="delta")
thread_id = _thread_id()
expected = [HumanMessage(content="history survives", id="message-1")]
await graph.ainvoke({"messages": expected}, _config(thread_id))
assert await persist_run_history_metadata(
checkpointer=wrapped,
thread_id=thread_id,
durations={"run-1": 3},
message_run_ids={"message-1": "run-1"},
)
duration_head = await inner.aget_tuple(_config(thread_id))
assert duration_head is not None
duration_head_id = duration_head.checkpoint["id"]
target_resolved = asyncio.Event()
allow_hydration = asyncio.Event()
original_aget_tuple = inner.aget_tuple
paused = False
async def pause_before_hydrating_target(config: dict[str, Any]) -> Any:
nonlocal paused
checkpoint_id = config.get("configurable", {}).get("checkpoint_id")
if checkpoint_id == duration_head_id and not paused:
paused = True
target_resolved.set()
await allow_hydration.wait()
return await original_aget_tuple(config)
monkeypatch.setattr(inner, "aget_tuple", pause_before_hydrating_target)
read_task = asyncio.create_task(accessor.aget(_config(thread_id)))
await asyncio.wait_for(target_resolved.wait(), timeout=2)
report = await enforce_completed_run_retention(
checkpointer=wrapped,
thread_id=thread_id,
)
assert report.deleted_checkpoint_ids == [duration_head_id]
allow_hydration.set()
snapshot = await asyncio.wait_for(read_task, timeout=2)
messages = snapshot.values["messages"]
assert [(message.id, message.content) for message in messages] == [
("message-1", "history survives"),
]
assert snapshot.config["configurable"]["checkpoint_id"] != duration_head_id
@pytest.mark.anyio
async def test_completed_run_retention_preserves_selected_duration_leaf(saver_env: _SaverEnv) -> None:
"""An admitted run may explicitly resume the metadata leaf it exposed."""
graph = _build_graph(FullState, saver_env.saver)
thread_id = _thread_id()
await graph.ainvoke({"messages": [HumanMessage(content="hello")]}, _config(thread_id))
assert await persist_run_durations(
checkpointer=saver_env.saver,
thread_id=thread_id,
durations={"run-1": 3},
)
selected = await saver_env.saver.aget_tuple(_config(thread_id))
assert selected is not None
selected_id = selected.checkpoint["id"]
report = await enforce_completed_run_retention(
checkpointer=saver_env.saver,
thread_id=thread_id,
protect_checkpoint_ids=frozenset({selected_id}),
)
assert report.deleted_checkpoint_ids == []
assert await saver_env.saver.aget_tuple(_config_thread(thread_id, selected_id)) is not None
@pytest.mark.anyio
@pytest.mark.parametrize("steps", [1, 40], ids=["short", "long"])
@pytest.mark.parametrize("duration_tail", [False, True], ids=["normal-head", "duration-head"])
async def test_admission_retention_bounds_storage_work_independently_of_history(
saver_env: _SaverEnv,
monkeypatch: pytest.MonkeyPatch,
steps: int,
duration_tail: bool,
) -> None:
thread_id, checkpoint_ids, _ = await _write_turns(saver_env, steps)
duration_id = await _append_duration_checkpoint(saver_env, thread_id) if duration_tail else None
original_get = saver_env.saver.aget_tuple
reads: list[dict[str, Any]] = []
async def count_read(config: dict[str, Any]) -> Any:
reads.append(config)
assert (config["configurable"].get("checkpoint_ns") or "") == ""
return await original_get(config)
def forbid_scan(*_args: Any, **_kwargs: Any) -> Any:
raise AssertionError("admission retention must not scan history, writes, stats or blobs")
monkeypatch.setattr(saver_env.saver, "aget_tuple", count_read)
monkeypatch.setattr(saver_env.saver, "alist", forbid_scan)
for helper in ("_thread_storage_stats", "_checkpoint_ids_with_writes", "_delete_unreachable_blobs"):
monkeypatch.setattr(checkpoint_retention, helper, forbid_scan)
report = await enforce_completed_run_retention(checkpointer=saver_env.saver, thread_id=thread_id)
assert len(reads) <= 2
assert report.deleted_checkpoint_ids == ([duration_id] if duration_tail else [])
assert report.stats_before == report.stats_after == {}
head = await original_get(_config(thread_id))
assert head is not None
assert head.checkpoint["id"] == checkpoint_ids[-1]
# The parent retains all shared blobs, even though no blob GC ran.
materialized = await _build_graph(FullState, saver_env.saver).aget_state(_config(thread_id))
assert len(materialized.values["messages"]) == steps
@pytest.mark.anyio
@pytest.mark.parametrize(
"shape",
["pending-writes", "missing-parent", "no-parent-link", "changed-versions", "swapped-channels", "markerless", "empty-durations", "empty-write-entry"],
)
async def test_admission_retention_checks_only_a_proven_metadata_leaf(
saver_env: _SaverEnv,
monkeypatch: pytest.MonkeyPatch,
shape: str,
) -> None:
thread_id, _, _ = await _write_turns(saver_env, 2)
duration_id = await _append_duration_checkpoint(saver_env, thread_id)
original_get = saver_env.saver.aget_tuple
head = await original_get(_config(thread_id))
assert head is not None
parent = await original_get(head.parent_config)
assert parent is not None
parent_id = parent.checkpoint["id"]
if shape == "empty-write-entry" and saver_env.kind == "memory":
saver_env.saver.writes[(thread_id, "", duration_id)] = {}
elif shape == "pending-writes":
await saver_env.saver.aput_writes(head.config, [("messages", [HumanMessage(content="pending")])], task_id="pending")
elif shape in {"markerless", "empty-durations"}:
metadata = copy.deepcopy(head.metadata)
metadata.pop("writes", None)
if shape == "empty-durations":
metadata["run_durations"] = {}
await saver_env.saver.aput(head.parent_config, head.checkpoint, metadata, {})
elif shape in {"changed-versions", "swapped-channels"}:
checkpoint = copy.deepcopy(head.checkpoint)
versions = checkpoint["channel_versions"]
if shape == "changed-versions":
versions["messages"] = "different-version"
else:
# Equal sets of version values do not prove identical channel maps.
versions["renamed-messages"] = versions.pop("messages")
await saver_env.saver.aput(head.parent_config, checkpoint, head.metadata, {})
elif shape in {"missing-parent", "no-parent-link"}:
async def incomplete_chain(config: dict[str, Any]) -> Any:
result = await original_get(config)
if result is None:
return None
if shape == "missing-parent" and result.checkpoint["id"] == parent_id:
return None
if shape == "no-parent-link" and result.checkpoint["id"] == duration_id:
return result._replace(parent_config=None)
return result
monkeypatch.setattr(saver_env.saver, "aget_tuple", incomplete_chain)
report = await enforce_completed_run_retention(checkpointer=saver_env.saver, thread_id=thread_id)
assert report.deleted_checkpoint_ids == ([duration_id] if shape in {"markerless", "empty-write-entry"} else [])
retained = await original_get(_config_thread(thread_id, duration_id))
assert (retained is None) == (shape in {"markerless", "empty-write-entry"})
@pytest.mark.anyio
@pytest.mark.parametrize("invalidation_fails", [False, True], ids=["invalidate", "fail-closed"])
async def test_admission_retention_invalidates_delta_cache_before_deleting_source(
saver_env: _SaverEnv,
monkeypatch: pytest.MonkeyPatch,
invalidation_fails: bool,
) -> None:
cache = MemoryCheckpointHistoryCache(max_entries=16)
wrapped = CachedHistorySaver(saver_env.saver, cache, key_prefix="retention-order")
graph = _build_graph(DeltaState, wrapped)
thread_id = _thread_id()
await graph.ainvoke({"messages": [HumanMessage(content="preserved", id="message-1")]}, _config(thread_id))
assert await persist_run_history_metadata(checkpointer=wrapped, thread_id=thread_id, durations={"run-1": 3})
head = await wrapped.aget_tuple(_config(thread_id))
assert head is not None
duration_id = head.checkpoint["id"]
key = make_history_key("retention-order", thread_id, "", duration_id, "messages")
await cache.aset_many({key: {"writes": []}})
original_invalidate = wrapped.ainvalidate_history_cache
invalidated: list[str] = []
async def invalidate_before_delete(selected_thread: str) -> None:
assert await wrapped.source_saver.aget_tuple(_config_thread(thread_id, duration_id)) is not None
invalidated.append(selected_thread)
if invalidation_fails:
raise RuntimeError("cache unavailable")
await original_invalidate(selected_thread)
monkeypatch.setattr(wrapped, "ainvalidate_history_cache", invalidate_before_delete)
if invalidation_fails:
with pytest.raises(RuntimeError, match="cache unavailable"):
await enforce_completed_run_retention(checkpointer=wrapped, thread_id=thread_id)
assert await saver_env.saver.aget_tuple(_config_thread(thread_id, duration_id)) is not None
assert cache.stats().entries == 1
else:
report = await enforce_completed_run_retention(checkpointer=wrapped, thread_id=thread_id)
assert report.deleted_checkpoint_ids == [duration_id]
assert cache.stats().entries == 0
assert (await graph.aget_state(_config(thread_id))).values["messages"][-1].content == "preserved"
assert invalidated == [thread_id]
@pytest.mark.anyio
async def test_admission_retention_leaves_subgraph_and_other_thread_checkpoints_untouched(saver_env: _SaverEnv) -> None:
graph = _build_nested_graph(saver_env.saver)
thread_id = _thread_id()
await graph.ainvoke({"value": 10}, _config(thread_id))
before = {tuple_.checkpoint["id"] async for tuple_ in saver_env.saver.alist(_config(thread_id), limit=None)}
other_thread, _, _ = await _write_turns(saver_env, 1)
other_leaf = await _append_duration_checkpoint(saver_env, other_thread)
duration_id = await _append_duration_checkpoint(saver_env, thread_id)
report = await enforce_completed_run_retention(checkpointer=saver_env.saver, thread_id=thread_id)
assert report.deleted_checkpoint_ids == [duration_id]
assert await _listed_checkpoint_ids(saver_env, thread_id) == before
assert await saver_env.saver.aget_tuple(_config_thread(other_thread, other_leaf)) is not None
assert (await graph.aget_state(_config(thread_id))).values["value"] == 11
@pytest.mark.anyio
@pytest.mark.parametrize("empty_thread", [False, True], ids=["selected-head", "empty-thread"])
async def test_admission_retention_skips_selected_or_missing_head_without_parent_reads(
saver_env: _SaverEnv,
monkeypatch: pytest.MonkeyPatch,
empty_thread: bool,
) -> None:
thread_id = _thread_id()
protected = frozenset()
if not empty_thread:
thread_id, _, _ = await _write_turns(saver_env, 1)
protected = frozenset({await _append_duration_checkpoint(saver_env, thread_id)})
original_get = saver_env.saver.aget_tuple
reads = 0
async def read_head(config: dict[str, Any]) -> Any:
nonlocal reads
reads += 1
assert "checkpoint_id" not in config["configurable"]
return await original_get(config)
def forbid_scan(*_args: Any, **_kwargs: Any) -> Any:
raise AssertionError("selected or missing heads need no history scan")
monkeypatch.setattr(saver_env.saver, "aget_tuple", read_head)
monkeypatch.setattr(saver_env.saver, "alist", forbid_scan)
report = await enforce_completed_run_retention(checkpointer=saver_env.saver, thread_id=thread_id, protect_checkpoint_ids=protected)
assert reads == 1
assert report.deleted_checkpoint_ids == []
@pytest.mark.anyio
async def test_gateway_context_keeps_agent_runs_binding_and_retention_hooks(monkeypatch: pytest.MonkeyPatch) -> None:
"""Main's delegation binding and admission cleanup must share one context."""
from app.gateway import deps
saver = InMemorySaver()
wrapped = CachedHistorySaver(saver, MemoryCheckpointHistoryCache(max_entries=16), key_prefix="context-retention")
thread_id = _thread_id()
await _build_graph(DeltaState, wrapped).ainvoke({"messages": [HumanMessage(content="preserved")]}, _config(thread_id))
assert await persist_run_durations(checkpointer=wrapped, thread_id=thread_id, durations={"prior": 1})
leaf = await saver.aget_tuple(_config(thread_id))
assert leaf is not None
leaf_id = leaf.checkpoint["id"]
delegation = object()
bound_requests: list[Any] = []
completed: list[Any] = []
class Host:
def bind(self, request: Any) -> Any:
bound_requests.append(request)
return delegation
class Scheduler:
async def handle_run_completion(self, record: Any) -> None:
completed.append(record)
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
agent_runs_host=Host(),
scheduled_task_service=Scheduler(),
checkpointer=wrapped,
store=object(),
run_event_store=object(),
thread_store=object(),
)
)
)
monkeypatch.setattr(deps, "get_config", lambda: SimpleNamespace())
context = deps.get_run_context(request)
assert context.agent_runs is delegation
assert bound_requests == [request]
assert context.checkpointer is wrapped
record = SimpleNamespace(thread_id=thread_id, run_id="next")
await context.on_run_admitted(record, {"configurable": {"checkpoint_map": {"": leaf_id}}})
assert await saver.aget_tuple(_config_thread(thread_id, leaf_id)) is not None
await context.on_run_admitted(record, _config(thread_id))
assert await saver.aget_tuple(_config_thread(thread_id, leaf_id)) is None
await context.on_run_completed(record)
assert completed == [record]
@pytest.mark.anyio
async def test_gateway_admission_retention_failure_isolated_from_scheduler_completion(monkeypatch: pytest.MonkeyPatch) -> None:
"""Retention remains best-effort and scheduled completion stays separate."""
from app.gateway import checkpoint_retention as retention_module
from app.gateway.deps import _run_admission_hook, _run_completion_hook
calls: list[tuple[str, Any]] = []
async def fail_retention(*, checkpointer: Any, thread_id: str, protect_checkpoint_ids: frozenset[str]):
calls.append(("retention", (checkpointer, thread_id, protect_checkpoint_ids)))
raise RuntimeError("checkpoint backend unavailable")
class _Scheduler:
async def handle_run_completion(self, record: Any) -> None:
calls.append(("scheduler", record))
monkeypatch.setattr(retention_module, "enforce_completed_run_retention", fail_retention)
checkpointer = object()
record = SimpleNamespace(thread_id="thread-1", run_id="run-1")
await _run_admission_hook(checkpointer=checkpointer)(
record,
{"configurable": {"checkpoint_id": "selected", "checkpoint_map": {"child": "mapped"}}},
)
await _run_completion_hook(
scheduled_task_service=_Scheduler(),
)(record)
assert calls == [
("retention", (checkpointer, "thread-1", frozenset({"selected", "mapped"}))),
("scheduler", record),
]
# ---------------------------------------------------------------------------
# Namespaced (persistent subgraph) histories
# ---------------------------------------------------------------------------

View File

@ -44,6 +44,7 @@ from deerflow.runtime.runs.worker import (
_LargeFileToolChunkBatcher,
_rollback_to_pre_run_checkpoint,
_try_extract_from_message,
persist_run_durations,
run_agent,
)
from deerflow.sandbox.lease import (
@ -70,6 +71,97 @@ def _lease_test_bridge():
)
@pytest.mark.anyio
async def test_admission_hook_holds_durable_thread_exclusivity_until_checkpoint_preflight() -> None:
"""A peer cannot admit a run while retention is paused in the pre-read hook."""
store = MemoryRunStore()
owner = RunManager(store=store, worker_id="owner")
peer = RunManager(store=store, worker_id="peer")
record = await owner.create_or_reject("retention-admission-race")
hook_entered = asyncio.Event()
release_hook = asyncio.Event()
async def hold_admission(_record, _config) -> None:
hook_entered.set()
await release_hook.wait()
class EmptyAgent:
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
del graph_input, config, stream_mode, subgraphs
yield {"messages": []}
task = asyncio.create_task(
run_agent(
_lease_test_bridge(),
owner,
record,
ctx=RunContext(checkpointer=None, on_run_admitted=hold_admission),
agent_factory=lambda **_kwargs: EmptyAgent(),
graph_input={},
config={},
)
)
await asyncio.wait_for(hook_entered.wait(), timeout=1)
with pytest.raises(ConflictError, match="active run"):
await peer.create_or_reject("retention-admission-race")
release_hook.set()
await asyncio.wait_for(task, timeout=1)
assert record.status == RunStatus.success
@pytest.mark.anyio
async def test_admission_cleanup_finishes_before_first_checkpoint_read(monkeypatch: pytest.MonkeyPatch) -> None:
"""A real saver is pruned while admission is exclusive, before preflight reads."""
from app.gateway.checkpoint_retention import enforce_completed_run_retention
checkpointer = InMemorySaver()
thread_id = "retention-real-checkpointer"
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
checkpoint = empty_checkpoint()
checkpoint["id"] = "00000000-0000-0000-0000-000000000001"
await checkpointer.aput(config, checkpoint, {"source": "input", "step": -1, "parents": {}}, {})
assert await persist_run_durations(checkpointer=checkpointer, thread_id=thread_id, durations={"prior": 1})
duration_head = await checkpointer.aget_tuple(config)
assert duration_head is not None
duration_id = duration_head.checkpoint["id"]
store = MemoryRunStore()
manager = RunManager(store=store, worker_id="owner")
peer = RunManager(store=store, worker_id="peer")
record = await manager.create_or_reject(thread_id)
async def admission_hook(_record, _config) -> None:
await enforce_completed_run_retention(checkpointer=checkpointer, thread_id=thread_id)
async def observe_preflight(*_args, **_kwargs):
head = await checkpointer.aget_tuple(config)
assert head is not None
assert head.checkpoint["id"] != duration_id
with pytest.raises(ConflictError, match="active run"):
await peer.create_or_reject(thread_id)
return None
monkeypatch.setattr("deerflow.runtime.runs.worker._capture_rollback_point", observe_preflight)
class EmptyAgent:
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
del graph_input, config, stream_mode, subgraphs
yield {"messages": []}
await run_agent(
_lease_test_bridge(),
manager,
record,
ctx=RunContext(checkpointer=checkpointer, on_run_admitted=admission_hook),
agent_factory=lambda **_kwargs: EmptyAgent(),
graph_input={},
config=config,
)
assert record.status == RunStatus.success
@pytest.mark.anyio
async def test_run_agent_releases_execution_lease_when_graph_raises():
provider = MagicMock()

View File

@ -77,10 +77,10 @@ Exactly two shapes are proven safe, and the retention service implements only th
Two mechanical constraints come with deletion:
- **Joint table deletion, orphan-first.** Deleting a checkpoint row requires deciding which writes and blob rows are orphaned; a row is an orphan only if no *surviving* checkpoint references it, computed in a whole-thread pass. A real duration-only leaf is not payload-free — it materializes the parent's values — and on Postgres its blob rows are the same rows backing its parent.
- **Preserve shared storage.** General retention computes blob reachability in a whole-thread pass. Admission cleanup instead requires the root duration leaf and its parent to have identical channel-version maps, deletes only the leaf's checkpoint/writes rows, and leaves all shared blobs intact.
- **All-or-nothing.** A partial deletion that leaves a dangling parent link converts a cleanup into a thread-level outage: branch and regenerate fail loudly for every later turn.
There is also a history fast-path interaction to sequence around: the trailing duration-only leaf carries the `run_durations` and `run_message_ids` maps that `get_thread_history` reads from the latest checkpoint. Deleting the leaf makes the next history read fall back to store scans and re-persist a fresh leaf, so any wiring must sequence retention away from history reads or spare cache-carrying leaves.
There is also a history fast-path interaction: the trailing duration-only leaf carries the `run_durations` and `run_message_ids` maps that `get_thread_history` reads from the latest checkpoint. The Gateway keeps that leaf available for history reads until the next run wins durable same-thread admission, then deletes it under the checkpoint lock before the run's first checkpoint read. Cleanup reads at most the root head and its parent and probes only the candidate's pending writes; it performs no history scan, statistics pass or blob GC. Explicit `checkpoint_id` and `checkpoint_map` selections are protected. Delta-history caches are invalidated before deletion, and a cache failure preserves the source checkpoint.
### Policy defaults
@ -92,11 +92,11 @@ There is also a history fast-path interaction to sequence around: the trailing d
| `strict_pending_write_guard` | `True` | Treat any checkpoint that still owns writes rows as protected |
| `max_delete_per_run` | `None` | Optional cap on deletions per run |
### There is no production trigger yet
### Production retention trigger
The retention service (`backend/app/gateway/checkpoint_retention.py`) ships **without a production trigger**: where retention is invoked from — post-run hook, scheduler, or explicit admin action — is a maintainer decision that lands with the contract itself. Nothing in DeerFlow calls it, and there is no production caller of `prune` or `aprune` either: in the pinned environment only the base implementation defines them, and it raises `NotImplementedError`.
After a run wins durable same-thread admission, the Gateway calls the conservative retention policy before its first checkpoint read. The admission fence and per-thread checkpoint lock exclude concurrent runs and writers; explicitly selected checkpoint IDs are protected. The hook deletes at most one contract-proven trailing metadata-only leaf and treats failures as non-fatal. It does not prune resumable ancestors or sibling branches.
Treat this as unfinished work rather than a safe default. The executable contract proves the branch-ancestor, explicit-resume-target, pending-writes, leaf-duration, and leaf-sibling scenarios against the **full-mode** state schema; only the growth baseline is delta-shaped. Delta-specific prune safety is neither proved by the suite nor implemented.
The production trailing-duration-leaf deletion has regressions against both **full** and **delta** state schemas. The broader branch-ancestor, explicit-resume-target, pending-writes, and sibling-branch contract is still primarily full-shaped; production does not attempt those deletions without a future rebase/TTL contract.
## The one remaining patch

View File

@ -361,7 +361,7 @@ State at the pinned set (`langgraph 1.2.9`, `langgraph-checkpoint 4.2.0`, `langg
| `#8448` | upstream langgraph issue; **open**; fix PR `#8556` unmerged | `PostgresSaver.get_delta_channel_history` permanently poisons the walk cursor when the target checkpoint is not in the first pagination page, so old checkpoints hydrate empty; only Postgres pages its stage-1 scan | Assertion stays live on memory/sqlite (differential against an `InMemorySaver` oracle); Postgres trigger-skips; `test_delta_channel_checkpointers.py::test_long_chain_history_survives_pagination` | `langgraph-checkpoint-postgres 3.1.2` | `test_delta_channel_checkpointers.py:459` |
| `#8548` | upstream langgraph PR; **open**; fixes upstream `#8443`, with related issue `#8551` still waiting on it | Does not replay an abandoned branch into a `DeltaChannel` fork | While unmerged the local linearization cannot be removed: `_linearize_delta_checkpoint_resume` is retained | `langgraph 1.2.9` | `worker:2267-2279`, `test_run_worker_delta_resume.py::test_linearizes_a_delta_resume_onto_the_head` |
| `#4458` | DeerFlow issue; the user-visible symptom of the fork bug | Regenerating in a branched thread resurrected the old assistant message beside the new one after a reload; reproduced on postgres, sqlite, and the in-memory saver, and it also broke branch history when seeded rows shared one run id | Linearization plus per-turn seeded run ids; `test_run_worker_delta_resume.py::test_regenerating_in_a_branched_thread_does_not_resurrect_the_old_answer` | `langgraph 1.2.9` | `worker:2267-2289`, `test_run_worker_delta_resume.py:204` |
| `#4189` item 3 | DeerFlow design discussion | Retention and deletion of checkpoint rows; what may be deleted | `backend/docs/checkpoint-retention-contract.md`, `backend/tests/test_checkpoint_retention_contract.py`, `backend/app/gateway/checkpoint_retention.py`; no production trigger is wired | — | `checkpoint_retention.py:1-40`, `backend/docs/checkpoint-retention-contract.md:31-77` |
| `#4189` item 3 | DeerFlow design discussion | Retention and deletion of checkpoint rows; what may be deleted | `backend/docs/checkpoint-retention-contract.md`, `backend/tests/test_checkpoint_retention_contract.py`, `backend/app/gateway/checkpoint_retention.py`; bounded cleanup runs at durable admission before checkpoint reads, protects explicit selections, and removes only the root trailing duration leaf | — | `checkpoint_retention.py`, `backend/docs/checkpoint-retention-contract.md:31-77` |
| `#3859` (review) | DeerFlow review reference | An interrupted-title write must bump `channel_versions["title"]` and declare it in `new_versions`, or database savers drop the blob | `backend/tests/test_run_worker_rollback.py:3501-3504` pins the shape | — | `backend/tests/test_run_worker_rollback.py:3501-3504` |
See [Operating Checkpoints](/docs/harness/checkpoints/operations) for what the remaining patch does and how the dependency floor is enforced, and [Troubleshooting](/docs/harness/checkpoints/troubleshooting) for the symptoms each defect produces for a user.

View File

@ -76,10 +76,10 @@ import { Callout } from "nextra/components";
删除还带两条机械约束:
- **联合表删除、先算孤儿。** 删除检查点行时必须判定哪些 writes 与 blob 行成了孤儿;只有当**幸存**检查点都不再引用某行时它才算孤儿,且要在整线程范围内计算。真实的仅耗时叶子并非没有负载——它会物化父节点的值——在 Postgres 上它的 blob 行与支撑父节点的正是同一批行。
- **保留共享存储。** 通用保留策略需要在整线程范围内计算 blob 可达性。Admission 清理则要求根命名空间的仅耗时叶子与父节点具有完全相同的通道版本映射,只删除该叶子的 checkpoint/writes 行,保留全部共享 blob。
- **要么全做,要么不做。** 留下悬空父链接的部分删除会把一次清理变成线程级故障:此后每一轮的分支与重新生成都会响亮失败。
还有一条历史快路径需要排期:尾部的仅耗时叶子同时承载 `run_durations` 与 `run_message_ids` 映射,`get_thread_history` 会从最新检查点读取它们。删掉该叶子会让下一次历史读取退化为扫描事件存储并重新落一份新叶子,因此接线时必须把保留动作与历史读错开,或采用会保留「带缓存的叶子」的策略。
这里还存在一条历史快路径交互:尾部的仅耗时叶子同时承载 `run_durations` 与 `run_message_ids` 映射,`get_thread_history` 会从最新检查点读取它们。Gateway 会保留该叶子供历史读取,直到下一次 run 获得持久化的同线程 admission;随后在首次 checkpoint 读取前、持有 checkpoint 锁时删除它。清理最多读取根命名空间的头节点与父节点,并只探测候选叶子的 pending writes;不扫描历史、不收集统计、不做 blob GC。显式 `checkpoint_id` 与 `checkpoint_map` 选择受到保护。删除前通过公共生命周期接口失效增量历史缓存;缓存失效失败时保留源检查点。
### 策略默认值
@ -91,11 +91,11 @@ import { Callout } from "nextra/components";
| `strict_pending_write_guard` | `True` | 仍持有 writes 行的检查点一律视为受保护 |
| `max_delete_per_run` | `None` | 单次 run 删除数量的可选上限 |
### 目前没有生产触发点
### 生产保留触发点
保留服务(`backend/app/gateway/checkpoint_retention.py`)**没有生产触发点**:保留动作从哪里调用——run 结束后钩子、调度器还是显式管理操作——是维护者随契约一起决定的。DeerFlow 中没有任何地方调用它;`prune` 与 `aprune` 也没有生产调用方:在锁定环境中只有基类实现定义了它们,而基类实现直接抛 `NotImplementedError`。
run 获得持久化的同线程 admission 后,Gateway 会在首次 checkpoint 读取前调用保守保留策略。admission fence 与逐线程 checkpoint 锁会排除并发 run 和写入;显式选择的 checkpoint ID 会加入保护集。该钩子每次最多删除一个已由契约证明安全的末尾纯元数据叶子,失败不会改变 run 结果,也不会删除可恢复祖先或兄弟分支。
请把它当作未完成的工作,而不是安全默认值。可执行契约只在 **full 模式**的状态 schema 上证明了分支祖先、显式恢复目标、待处理写入、叶子耗时、叶子兄弟这几类场景;只有增长基线是增量形态的。增量专属的裁剪安全性既未被测试套件证明,也未被实现。
生产触发的末尾耗时叶子删除在 **full** 和 **delta** schema 上都有回归测试。更广泛的分支祖先、显式恢复目标、待处理写入和兄弟分支契约仍以 full schema 为主;在没有 rebase/TTL 契约之前,生产策略不会尝试删除这些历史。
## 仅存的一个补丁