deer-flow/backend/packages/harness/deerflow/checkpoint_patches.py
Zeren Wang 31bfd40f38
chore(deps): bump langgraph-checkpoint to 4.2.0 / postgres 3.1.2 and delete the saver patch (#5734)
* chore(checkpoint): bump to checkpoint 4.2.0 and drop the stale saver patch

langgraph-checkpoint 4.2.0 fixes the write dropped after a full -> delta
migration (upstream langchain-ai/langgraph#8526) and keeps its own
InMemorySaver override, so the compatibility patch in checkpoint_patches.py
could neither detect the fix nor stand down: it read langgraph's version
while InMemorySaver ships in langgraph-checkpoint, and it would have kept
shadowing the upstream implementation on 4.2.0. The postgres release 3.1.2
locates plain-value delta seeds (upstream #8535).

- floor langgraph-checkpoint>=4.2.0,<5.0 in the harness and raise the postgres
  extra to >=3.1.2,<3.2; langgraph's own >=4.1.0 would otherwise let a
  resolver reintroduce the bug now that the patch is gone
- delete the patch, its version guard, and the three implementation-assertion
  tests; test_full_to_delta_migration_replays_on_same_thread stays as the gate
  and fails on 4.1.1 without the patch (verified by a one-off unpatch run)
- langgraph stays 1.2.9 and langgraph-checkpoint-sqlite stays 3.1.1

Verification: make lint; delta checkpointer/cache/worker-resume suites green
with the migration gate passing on memory and sqlite (postgres params skip
without TEST_POSTGRES_URI). The full make test run before the packaging-pin
fix below was 13 failed / 18523 passed; 12 of those failures reproduce on main
with the same environment (git stash baseline, identical failure sets), 7 of
them because the local config.yaml runs checkpoint_channel_mode: delta. The
remaining one, the postgres-extra string pin in tests/test_checkpointer.py, is
updated here.

Upstream status for the surrounding gaps: cache parity across off/cold/warm
states, postgres pagination (langchain-ai/langgraph#8448) and parallel-superstep
replay order (#8382) are still open upstream, as is the abandoned-branch fork
fix (#8548) that the delta resume linearization in runtime/runs/worker.py works
around, so that path stays.

* docs(changelog): keep checkpoint upgrade in Unreleased

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-23 09:18:26 +08:00

126 lines
5.9 KiB
Python

"""Compatibility patches for third-party checkpoint machinery.
Lives at the top-level package (not ``deerflow.runtime``) so it can be
imported from ``deerflow.agents.thread_state`` without pulling in the heavy
``deerflow.runtime`` package __init__ (which eagerly imports the runs
machinery). Anchored from ``deerflow.agents.thread_state`` so every process
that builds a DeerFlow graph (gateway, workers, in-process LangGraph
runtime, tests) runs with the fixes in place.
One patch remains: ``BinaryOperatorAggregate`` unwrapping an ``Overwrite``
first write into an empty (MISSING) channel (#4380). The former
``InMemorySaver`` delta-history patch was removed: upstream fixed the dropped
first post-migration write in ``langgraph-checkpoint`` 4.2.0 (upstream #8526)
while keeping its own override, so a behavioural probe cannot see the fix.
The harness dependency floor (``langgraph-checkpoint>=4.2.0``) is what keeps
that bug out, and
``tests/test_delta_channel_checkpointers.py::test_full_to_delta_migration_replays_on_same_thread``
is the regression gate that fails when the fix is absent. Do not re-add a
version-guarded saver patch: the guard that used to live here read
``langgraph``'s version, but ``InMemorySaver`` ships in the independently
released ``langgraph-checkpoint`` distribution.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from typing import Any
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.errors import ErrorCode, InvalidUpdateError, create_error_message
from langgraph.types import Overwrite
logger = logging.getLogger(__name__)
_BINOP_PATCH_FLAG = "_deerflow_overwrite_first_write_patched"
_unpatched_binop_update = BinaryOperatorAggregate.update
def _as_overwrite(value: Any) -> tuple[bool, Any]:
"""Local stand-in for langgraph's private ``_get_overwrite``.
Matches only the public ``Overwrite`` *class* form - the sole form
DeerFlow's write paths produce for these Union channels (the branch and
``/state`` routes wrap replace-style writes in ``Overwrite(...)``).
Avoiding the underscored ``_get_overwrite`` import keeps an upstream
refactor that drops it - plausibly the very release that fixes the bug -
from failing this module's import and crashing startup before the probe
can stand the patch down. The dict sentinel form upstream also accepts is
an internal serialization detail DeerFlow never emits into these channels.
"""
if isinstance(value, Overwrite):
return True, value.value
return False, None
def _binop_first_write_stores_overwrite_wrapper() -> bool:
"""Probe whether upstream still stores an Overwrite first write literally.
Uses a Union-typed channel (no constructible default, so it starts
MISSING) - the same shape as ``ThreadState``'s ``sandbox`` / ``goal`` /
``todos`` / ``promoted`` channels.
"""
channel = BinaryOperatorAggregate(dict | None, lambda existing, new: new)
channel.key = "deerflow-overwrite-probe"
channel.update([Overwrite({"probe": True})])
return isinstance(channel.get(), Overwrite)
def _binop_update_unwrapping_empty_channel(self: Any, values: Sequence[Any]) -> bool:
"""``BinaryOperatorAggregate.update`` that unwraps an Overwrite first write.
Only intercepts the empty-channel + leading-Overwrite case; everything
else delegates to the upstream implementation. The intercepted case
mirrors upstream's own post-Overwrite batch semantics: later plain values
are skipped and a second Overwrite raises ``InvalidUpdateError``.
"""
if not self.is_available() and values:
is_overwrite, overwrite_value = _as_overwrite(values[0])
if is_overwrite:
self.value = overwrite_value
for value in values[1:]:
if _as_overwrite(value)[0]:
msg = create_error_message(
message="Can receive only one Overwrite value per super-step.",
error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
)
raise InvalidUpdateError(msg)
return True
return _unpatched_binop_update(self, values)
def ensure_binop_overwrite_first_write_patch() -> None:
"""Fix ``Overwrite`` first writes being stored literally on empty channels.
Upstream ``BinaryOperatorAggregate.update`` seeds an empty channel
(``self.value is MISSING``) with ``values[0]`` verbatim - without the
Overwrite unwrapping the rest of the method applies. Channels whose type
is a Union (``SandboxState | None``, ``GoalState | None``, ...) have no
constructible default, so they start MISSING; a replace-style write into
a fresh thread (thread branching) or a never-written channel (state
update) then persists the ``Overwrite`` wrapper itself into the
checkpoint, and the next consumer crashes with ``TypeError: 'Overwrite'
object is not subscriptable`` (#4380). ``DeltaChannel.update`` already
unwraps in the same situation, so this also removes a behavioral
inconsistency between the two reducer channel types.
Idempotent. Guarded by a behavioral probe instead of a version pin: if a
future LangGraph unwraps the first write itself, the probe reports the
bug as absent and the patch stands down.
"""
if getattr(BinaryOperatorAggregate, _BINOP_PATCH_FLAG, False):
return
try:
if not _binop_first_write_stores_overwrite_wrapper():
# Upstream unwraps the first write itself: nothing to patch.
return
BinaryOperatorAggregate.update = _binop_update_unwrapping_empty_channel # type: ignore[method-assign]
setattr(BinaryOperatorAggregate, _BINOP_PATCH_FLAG, True)
except Exception:
logger.warning("Failed to apply the BinaryOperatorAggregate Overwrite first-write patch; leaving the upstream implementation untouched.", exc_info=True)
ensure_binop_overwrite_first_write_patch()