mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
* fix(gateway): classify duration-only checkpoints without the Postgres-dropped marker On Postgres the savers funnel metadata through langgraph's get_serializable_checkpoint_metadata, which pops writes before the row lands, so persist_run_history_metadata's runtime_run_duration marker never comes back and every replay/history read path treats metadata-only duration leaves as addressable conversation state. is_duration_only_checkpoint now classifies in two tiers: - writes present (memory/SQLite) is authoritative — the per-writer key is exact, so a title leaf carrying runtime_interrupt_title with inherited run_durations is not a duration checkpoint; - writes absent (Postgres) falls back to the surviving stamps (source == run_durations / run_message_ids keys). Stamps alone are not exact: _ensure_interrupted_title and _rollback_to_pre_run_checkpoint copy the head's metadata and inherit them. Read paths tolerate that because every stamp-bearing leaf either copies its parent's messages verbatim or restores an ancestor's exact message state, so a skipped leaf only advances a message-presence scan onto a state-equivalent checkpoint. Destructive callers opt out via stamp_fallback=False: enforce_thread_retention keeps widening the class only through the parent-shape check in _mark_duration_leaves_without_the_marker. * fix(gateway): confirm the duration stamp fallback by parent shape Review on #5988 surfaced a real false positive: write_thread_goal copies the head's metadata and thereby inherits the run_durations/run_message_ids stamps while changing the goal channel, and on Postgres its own writes.goal marker is popped too — so the stamp fallback classified the goal leaf as duration-only and the branch scan walked past it, copying channel values from before the goal write. The stamp fallback now also requires the duration writer's shape: it clones the parent's channel_versions verbatim, while every state-changing leaf (title, goal) bumps at least one version. Callers resolve the parent from their own context — the lineage walk fetches the grandparent only for stamp candidates, the history-window scans (branch, latest-turn, regenerate filter, chronological fallback) index the window they already hold — and stamps without a resolvable parent are refused: scanning one state-equivalent copy is free, skipping a goal leaf is not. Also corrects the mechanism claims from the first pass: langgraph's update_state paths (rollback, compaction, manual updates) persist metadata built from scratch and cannot inherit the stamps at all; the inheriting writers are title and goal. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(gateway): share the stamp gate and reuse the walked grandparent Review round 2 on #5988 — all three items, no functional changes: - extract _has_postgres_stamps as the single gate shared by the classifier fallback tier and the walk probe, so widening one widens the other; - carry the grandparent fetched for a stamp candidate into the next iteration (it is exactly the next parent), turning a chain of N Postgres duration leaves from ~2N+1 ancestor reads into N+1; - pin the unresolvable-grandparent promise at walk level: a stamp candidate whose own parent link was pruned stays addressable and remains the replay base. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(gateway): resolve persisted channel versions for the duration stamp fallback Materialized accessor snapshots expose no checkpoint payload, so the verbatim-copy shape check always failed open and Postgres duration leaves stayed addressable. Carry the version maps alongside the snapshots (raw snapshots expose them directly, graph-backed ones re-read the raw tuple through the accessor) and use them at every replay-base scan site. * perf(gateway): resolve stamp-fallback versions lazily and once per tuple The Postgres stamp fallback needs the raw channel versions for its shape check, and both window scans resolved them eagerly for every entry: a branch request paid up to 2N serial roundtrips for a window of N, and the regenerate scan resolved the same tuples a third time through resolve_history_versions. Resolve them only for stamp candidates - the marker tier and the stamp gate decide without versions - and memoize by config identity, so a marker-decided window pays nothing and each distinct tuple pays at most one raw read. The lineage walk had the same shape: a stamp candidate the shape check refuses re-read its grandparent as the next iteration's parent, because the prefetched slot was only filled when the candidate was skipped. Ancestor reads are now memoized by config identity, so no ancestor is fetched twice. Dropped the dead checkpointer parameter of find_checkpoint_before_message_chronologically: no caller passed it, and wiring one in from an async handler would put a blocking get_tuple on the event loop. --------- Co-authored-by: zeng-bohan <310902929+zeng-bohan@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
220 lines
8.8 KiB
Python
220 lines
8.8 KiB
Python
"""Materialized checkpoint-state access and state-only mutation graphs.
|
|
|
|
:class:`CheckpointStateAccessor` is the single choke point for thread
|
|
checkpoint-state reads and writes. It binds a compiled graph (which carries
|
|
the mode-matched channel schema), a checkpointer, and the frozen channel mode:
|
|
every operation injects the mode marker into the config and passes the
|
|
compatibility gate before touching state. Delta checkpoints store no full
|
|
``channel_values`` — raw saver reads see sentinels — so consumers must go
|
|
through this accessor instead of calling the checkpointer directly.
|
|
|
|
:func:`build_state_mutation_graph` compiles a state-only graph (one no-op
|
|
node, entry = finish) for wholesale state replacement such as rollback
|
|
restore and context compaction: it shares the agent graph's checkpoint
|
|
machinery but schedules no pending nodes, so the written head stays idle.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from deerflow.agents.thread_state import get_thread_state_schema
|
|
from deerflow.config.database_config import CheckpointChannelMode
|
|
from deerflow.runtime.checkpoint_mode import (
|
|
aensure_checkpoint_mode_compatible,
|
|
ensure_checkpoint_mode_compatible,
|
|
inject_checkpoint_mode,
|
|
raise_if_checkpoint_tuple_incompatible,
|
|
raise_if_snapshot_incompatible,
|
|
)
|
|
|
|
|
|
def _finish_state_mutation(_state: dict[str, Any]) -> dict[str, Any]:
|
|
return {}
|
|
|
|
|
|
def build_state_mutation_graph(
|
|
as_node: str,
|
|
mode: CheckpointChannelMode,
|
|
state_schema: Any | None = None,
|
|
*,
|
|
snapshot_frequency: int | None = None,
|
|
) -> Any:
|
|
"""Compile a state-only graph whose single writer node finishes immediately.
|
|
|
|
``update_state(..., as_node=...)`` requires the node to be registered in
|
|
the graph; a dedicated single-node graph applies reducer writes and
|
|
finishes, so the mutation checkpoint schedules no agent nodes and has no
|
|
pending ``next`` nodes.
|
|
|
|
``state_schema`` must be the thread's *effective* schema (the class the
|
|
assistant graph was compiled with) whenever the write carries materialized
|
|
state: the base ThreadState fallback does not know channels contributed by
|
|
custom middleware, and writes to unknown channels are silently discarded.
|
|
The fallback resolves the delta snapshot cadence explicit arg ->
|
|
process-frozen -> config default; an explicit ``state_schema`` already
|
|
carries its cadence in its identity.
|
|
"""
|
|
if not as_node:
|
|
raise ValueError("as_node is required for checkpoint state mutation")
|
|
from langgraph.graph import StateGraph
|
|
|
|
builder = StateGraph(state_schema if state_schema is not None else get_thread_state_schema(mode, snapshot_frequency))
|
|
builder.add_node(as_node, _finish_state_mutation)
|
|
builder.set_entry_point(as_node)
|
|
builder.set_finish_point(as_node)
|
|
return builder.compile()
|
|
|
|
|
|
def graph_state_schema(graph: Any) -> Any | None:
|
|
"""Return the state schema class a compiled graph was built with.
|
|
|
|
The schema is the first entry of ``StateGraph.schemas`` (state schema is
|
|
registered before input/output schemas). Returns ``None`` for stub
|
|
accessors in tests that do not wrap a real compiled graph.
|
|
"""
|
|
schemas = getattr(getattr(graph, "builder", None), "schemas", None)
|
|
if not schemas:
|
|
return None
|
|
return next(iter(schemas))
|
|
|
|
|
|
def graph_writable_channels(graph: Any) -> frozenset[str] | None:
|
|
"""Return the user-visible state channel names of a compiled graph.
|
|
|
|
Excludes Pregel-internal channels (``__*``) and branch fan-in channels
|
|
(``branch:*``). Returns ``None`` when the graph does not expose channels
|
|
(stub accessors), so callers can fall back to the base ThreadState set.
|
|
"""
|
|
channels = getattr(graph, "channels", None)
|
|
if not channels:
|
|
return None
|
|
return frozenset(name for name in channels if not name.startswith("__") and not name.startswith("branch:"))
|
|
|
|
|
|
def graph_reducer_channels(graph: Any) -> frozenset[str] | None:
|
|
"""Return channel names whose writes merge through a reducer.
|
|
|
|
Covers classic reducers (``BinaryOperatorAggregate``) and delta channels:
|
|
both require ``Overwrite`` wrapping for replace-style writes, in any mode.
|
|
Returns ``None`` when the graph does not expose channels (stub
|
|
accessors), so callers can fall back to the base ThreadState set.
|
|
"""
|
|
from langgraph.channels import BinaryOperatorAggregate, DeltaChannel
|
|
|
|
channels = getattr(graph, "channels", None)
|
|
if channels is None:
|
|
return None
|
|
return frozenset(name for name, channel in channels.items() if isinstance(channel, (BinaryOperatorAggregate, DeltaChannel)))
|
|
|
|
|
|
@dataclass
|
|
class CheckpointStateAccessor:
|
|
graph: Any
|
|
checkpointer: Any
|
|
mode: CheckpointChannelMode
|
|
|
|
@classmethod
|
|
def bind(
|
|
cls,
|
|
graph: Any,
|
|
checkpointer: Any,
|
|
*,
|
|
store: Any | None = None,
|
|
mode: CheckpointChannelMode = "full",
|
|
) -> CheckpointStateAccessor:
|
|
graph.checkpointer = checkpointer
|
|
if store is not None:
|
|
graph.store = store
|
|
return cls(graph=graph, checkpointer=checkpointer, mode=mode)
|
|
|
|
def _prepare_config(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
prepared = {
|
|
**config,
|
|
"configurable": dict(config.get("configurable", {})),
|
|
"metadata": dict(config.get("metadata", {})),
|
|
}
|
|
inject_checkpoint_mode(prepared, self.mode)
|
|
return prepared
|
|
|
|
def get(self, config: dict[str, Any]) -> Any:
|
|
prepared = self._prepare_config(config)
|
|
snapshot = self.graph.get_state(prepared)
|
|
raise_if_snapshot_incompatible(snapshot, self.mode)
|
|
return snapshot
|
|
|
|
async def aget(self, config: dict[str, Any]) -> Any:
|
|
prepared = self._prepare_config(config)
|
|
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:
|
|
"""Raw checkpoint tuple for a config, without materializing state."""
|
|
|
|
return self.checkpointer.get_tuple(self._prepare_config(config))
|
|
|
|
async def aget_tuple(self, config: dict[str, Any]) -> Any | None:
|
|
"""Raw checkpoint tuple for a config, without materializing state."""
|
|
|
|
return await self.checkpointer.aget_tuple(self._prepare_config(config))
|
|
|
|
def get_metadata(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
"""Read checkpoint metadata without materializing channel state."""
|
|
checkpoint_tuple = self.checkpointer.get_tuple(self._prepare_config(config))
|
|
raise_if_checkpoint_tuple_incompatible(checkpoint_tuple, self.mode)
|
|
return dict(getattr(checkpoint_tuple, "metadata", {}) or {})
|
|
|
|
async def aget_metadata(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
"""Read checkpoint metadata without materializing channel state."""
|
|
checkpoint_tuple = await self.checkpointer.aget_tuple(self._prepare_config(config))
|
|
raise_if_checkpoint_tuple_incompatible(checkpoint_tuple, self.mode)
|
|
return dict(getattr(checkpoint_tuple, "metadata", {}) or {})
|
|
|
|
def history(self, config: dict[str, Any], *, limit: int | None = None) -> list[Any]:
|
|
prepared = self._prepare_config(config)
|
|
if limit is not None and limit <= 0:
|
|
return []
|
|
result = []
|
|
for snapshot in self.graph.get_state_history(prepared, limit=limit):
|
|
raise_if_snapshot_incompatible(snapshot, self.mode)
|
|
result.append(snapshot)
|
|
if limit is not None and len(result) >= limit:
|
|
break
|
|
return result
|
|
|
|
async def ahistory(self, config: dict[str, Any], *, limit: int | None = None) -> list[Any]:
|
|
prepared = self._prepare_config(config)
|
|
if limit is not None and limit <= 0:
|
|
return []
|
|
result = []
|
|
async for snapshot in self.graph.aget_state_history(prepared, limit=limit):
|
|
raise_if_snapshot_incompatible(snapshot, self.mode)
|
|
result.append(snapshot)
|
|
if limit is not None and len(result) >= limit:
|
|
break
|
|
return result
|
|
|
|
def update(
|
|
self,
|
|
config: dict[str, Any],
|
|
values: dict[str, Any],
|
|
*,
|
|
as_node: str | None = None,
|
|
) -> dict[str, Any]:
|
|
prepared = self._prepare_config(config)
|
|
ensure_checkpoint_mode_compatible(self.checkpointer, prepared, self.mode)
|
|
return self.graph.update_state(prepared, values, as_node=as_node)
|
|
|
|
async def aupdate(
|
|
self,
|
|
config: dict[str, Any],
|
|
values: dict[str, Any],
|
|
*,
|
|
as_node: str | None = None,
|
|
) -> dict[str, Any]:
|
|
prepared = self._prepare_config(config)
|
|
await aensure_checkpoint_mode_compatible(self.checkpointer, prepared, self.mode)
|
|
return await self.graph.aupdate_state(prepared, values, as_node=as_node)
|