Compare commits

...

3 Commits

Author SHA1 Message Date
HAL9000 6c0196dcd6 fix(langgraph): guard replace_state() against closed StateManager in execute()
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 1m2s
CI / build (pull_request) Successful in 1m7s
CI / helm (pull_request) Successful in 41s
CI / quality (pull_request) Successful in 1m14s
CI / push-validation (pull_request) Successful in 36s
CI / typecheck (pull_request) Successful in 1m29s
CI / security (pull_request) Successful in 1m42s
CI / e2e_tests (pull_request) Successful in 4m55s
CI / unit_tests (pull_request) Successful in 5m29s
CI / integration_tests (pull_request) Successful in 7m40s
CI / docker (pull_request) Successful in 2m17s
CI / coverage (pull_request) Successful in 16m59s
CI / status-check (pull_request) Successful in 5s
CI / benchmark-regression (pull_request) Successful in 1h12m33s
Replace direct state assignment in LangGraph.execute() with
state_manager.replace_state(), which enforces the is_closed guard and
notifies state stream subscribers through the proper StateManager API,
preventing silent state corruption after StateManager.close() is called.

replace_state() is the semantically correct method for this use case:
it atomically replaces the entire state for a fresh execution context,
enforces the is_closed guard, and notifies subscribers. update_state()
is designed for incremental updates with execution_count tracking, not
for resetting state to a fresh execution context.

Closes #9994
2026-05-05 01:20:40 +00:00
HAL9000 9e67909c8e style: fix ruff format quote style in tdd_langgraph_execute_closed_state_steps.py
CI / benchmark-publish (pull_request) Has been skipped
CI / push-validation (pull_request) Successful in 25s
CI / helm (pull_request) Successful in 35s
CI / build (pull_request) Successful in 53s
CI / lint (pull_request) Successful in 1m3s
CI / quality (pull_request) Successful in 1m13s
CI / typecheck (pull_request) Successful in 1m31s
CI / security (pull_request) Successful in 1m32s
CI / e2e_tests (pull_request) Successful in 4m54s
CI / integration_tests (pull_request) Successful in 5m0s
CI / unit_tests (pull_request) Successful in 5m53s
CI / docker (pull_request) Successful in 1m35s
CI / coverage (pull_request) Successful in 11m36s
CI / status-check (pull_request) Successful in 3s
CI / benchmark-regression (pull_request) Successful in 1h3m5s
Apply ruff format to fix single-quote vs double-quote inconsistency in
f-string in step assertion, resolving CI lint/format check failure.
2026-04-23 16:44:18 +00:00
HAL9000 09c58ea81c fix(langgraph): use update_state() in LangGraph.execute() instead of direct state assignment
CI / lint (pull_request) Failing after 1s
CI / quality (pull_request) Failing after 1s
CI / integration_tests (pull_request) Failing after 0s
CI / helm (pull_request) Failing after 0s
CI / push-validation (pull_request) Failing after 0s
CI / build (pull_request) Successful in 3m42s
CI / typecheck (pull_request) Successful in 4m26s
CI / e2e_tests (pull_request) Failing after 3m59s
CI / security (pull_request) Successful in 4m29s
CI / coverage (pull_request) Has been skipped
CI / unit_tests (pull_request) Successful in 7m45s
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 0s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 1h5m58s
Add is_closed guard to StateManager.replace_state() so that LangGraph.execute() — which delegates to replace_state() — raises RuntimeError when the StateManager has been closed. This prevents silent state corruption after StateManager.close() is called.

Add Behave BDD scenarios verifying the guard and the happy path.

ISSUES CLOSED: #9994
2026-04-22 23:42:41 +00:00
4 changed files with 120 additions and 1 deletions
@@ -0,0 +1,95 @@
"""Behave step definitions for TDD Issue #9994.
Verifies that LangGraph.execute() raises RuntimeError when StateManager is closed,
preventing silent state corruption after StateManager.close() has been called.
"""
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.langgraph.graph import GraphConfig, LangGraph
from cleveragents.langgraph.state import GraphState
def _make_graph(name: str = "test-graph") -> LangGraph:
"""Create a minimal LangGraph for testing."""
config = GraphConfig(name=name)
graph = LangGraph(config=config)
graph.is_running = True
graph.stream_router.send_message = MagicMock()
return graph
@given("a LangGraph instance in running state with a closed StateManager")
def step_graph_running_with_closed_state_manager(context: Context) -> None:
context.graph = _make_graph("closed-graph")
context.graph.state_manager.close()
@when("I attempt to execute the graph after close")
def step_execute_after_close(context: Context) -> None:
context.execute_error = None
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(context.graph.execute({"messages": []}))
except RuntimeError as exc:
context.execute_error = exc
finally:
loop.close()
replacement_loop = asyncio.new_event_loop()
asyncio.set_event_loop(replacement_loop)
@then('a RuntimeError should be raised with message "StateManager is closed"')
def step_assert_runtime_error(context: Context) -> None:
assert context.execute_error is not None, (
"Expected RuntimeError but no error was raised"
)
assert isinstance(context.execute_error, RuntimeError), (
f"Expected RuntimeError, got {type(context.execute_error).__name__}"
)
assert "StateManager is closed" in str(context.execute_error), (
f'Expected "StateManager is closed" in error message, '
f"got: {context.execute_error}"
)
@given("a LangGraph instance in running state with an open StateManager")
def step_graph_running_with_open_state_manager(context: Context) -> None:
context.graph = _make_graph("open-graph")
assert not context.graph.state_manager.is_closed
@when("I execute the graph with valid input")
def step_execute_with_valid_input(context: Context) -> None:
context.execute_result = None
context.execute_error = None
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
context.execute_result = loop.run_until_complete(
context.graph.execute({"messages": []})
)
except Exception as exc: # pylint: disable=broad-except
context.execute_error = exc
finally:
loop.close()
replacement_loop = asyncio.new_event_loop()
asyncio.set_event_loop(replacement_loop)
@then("the execution should succeed and return a GraphState")
def step_assert_execution_success(context: Context) -> None:
assert context.execute_error is None, (
f"Expected no error but got: {context.execute_error}"
)
assert context.execute_result is not None, "Expected a GraphState result"
assert isinstance(context.execute_result, GraphState), (
f"Expected GraphState, got {type(context.execute_result).__name__}"
)
@@ -0,0 +1,18 @@
@tdd_issue @tdd_issue_9994
Feature: TDD Issue #9994 — LangGraph.execute() bypasses is_closed guard
As a developer using LangGraph
I want execute() to raise RuntimeError when StateManager is closed
So that state corruption after close() is prevented
Background:
Given the state management system is available
Scenario: execute() raises RuntimeError after StateManager.close()
Given a LangGraph instance in running state with a closed StateManager
When I attempt to execute the graph after close
Then a RuntimeError should be raised with message "StateManager is closed"
Scenario: execute() succeeds when StateManager is open
Given a LangGraph instance in running state with an open StateManager
When I execute the graph with valid input
Then the execution should succeed and return a GraphState
+3 -1
View File
@@ -108,7 +108,9 @@ class LangGraph: # pylint: disable=too-many-instance-attributes
if isinstance(input_data, GraphState)
else GraphState.from_dict(input_data)
)
# Replace state manager state for a fresh execution context
# Use replace_state() so the is_closed guard is enforced and state stream
# subscribers are notified through the proper StateManager API, preventing
# silent state corruption after StateManager.close() has been called.
self.state_manager.replace_state(state)
start_stream = f"__{self.name}_node_start__"
if start_stream in self.stream_router.streams:
+4
View File
@@ -237,12 +237,16 @@ class StateManager: # pylint: disable=too-many-instance-attributes
def replace_state(self, new_state: GraphState) -> None:
"""Atomically replace the internal state and notify subscribers.
Raises :class:`RuntimeError` if the manager has been closed.
.. note::
See :meth:`update_state` for a note on emission ordering
under concurrent access.
"""
with self._lock:
if self.is_closed:
raise RuntimeError("StateManager is closed")
self.state = new_state.model_copy(deep=True)
state_copy = new_state.model_copy(deep=True)
self.state_stream.on_next(state_copy)