fix(langgraph): let other tasks finish when a resume value is invalid

When one answer in a multi-answer resume fails `response_schema`
validation, the runner cancelled the other tasks in the step. In async
runs a task cancelled after its side effect kept its saved answer and ran
again on the next resume. Invalid resume values no longer stop the other
tasks; the error is raised once they finish, so their results are saved.
This commit is contained in:
byhow
2026-10-09 10:19:10 -07:00
parent cba111d8d6
commit 53188159a6
2 changed files with 64 additions and 3 deletions
+7 -2
View File
@@ -41,7 +41,7 @@ from langgraph._internal._future import chain_future, run_coroutine_threadsafe
from langgraph._internal._scratchpad import PregelScratchpad
from langgraph._internal._typing import MISSING
from langgraph.constants import TAG_HIDDEN
from langgraph.errors import GraphBubbleUp, GraphInterrupt
from langgraph.errors import GraphBubbleUp, GraphInterrupt, is_invalid_resume
from langgraph.pregel._algo import Call
from langgraph.pregel._executor import Submit
from langgraph.pregel._retry import arun_with_retry, run_with_retry
@@ -621,7 +621,11 @@ def _should_stop_others(
handled_exception_ids: set[int] | None = None,
) -> bool:
"""Check if any task failed, if so, cancel all other tasks.
GraphInterrupts are not considered failures."""
GraphInterrupts are not considered failures.
Neither is an invalid resume value: it's raised once the other tasks finish, so a
task answered in the same resume isn't cut off after its side effects and then run
again with its saved answer."""
for fut in done:
if fut.cancelled():
continue
@@ -629,6 +633,7 @@ def _should_stop_others(
if (
id(exc) not in (handled_exception_ids or set())
and not isinstance(exc, GraphBubbleUp)
and not is_invalid_resume(exc)
and fut not in SKIP_RERAISE_SET
):
return True
+57 -1
View File
@@ -1,5 +1,7 @@
import asyncio
import operator
from dataclasses import dataclass
from typing import Any
from typing import Annotated, Any
import pytest
from langgraph.checkpoint.base import BaseCheckpointSaver
@@ -251,3 +253,57 @@ def test_interrupt_response_schema_invalid_resume_after_earlier_interrupt(
assert graph.invoke(resume({"approved": True}), config) == {
"answer": ["ok", Decision(approved=True)]
}
async def test_invalid_resume_lets_other_answered_tasks_finish(
async_checkpointer: BaseCheckpointSaver,
) -> None:
"""An invalid answer fails the resume without cutting off the other answered tasks.
A task cancelled after its side effect keeps its answer and runs again on the next
resume, so the side effect would happen twice.
"""
sent: list[str] = []
class State(TypedDict):
log: Annotated[list[str], operator.add]
async def send(state: State) -> State:
interrupt("send?", response_schema=Decision)
sent.append("email")
await asyncio.sleep(0.05) # waiting on the reply to a request already sent
return {"log": ["sent"]}
async def check(state: State) -> State:
interrupt("check?", response_schema=Decision)
return {"log": ["checked"]}
graph = (
StateGraph(State)
.add_node("send", send)
.add_node("check", check)
.add_edge(START, "send")
.add_edge(START, "check")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}}
await graph.ainvoke({"log": []}, config)
ids = {i.value: i.id for i in (await graph.aget_state(config)).interrupts}
with pytest.raises(ValidationError, match="approved"):
await graph.ainvoke(
Command(
resume={
ids["send?"]: {"approved": True},
ids["check?"]: {"approved": "nope"},
}
),
config,
)
pending = [i.value for i in (await graph.aget_state(config)).interrupts]
assert (pending, sent) == (["check?"], ["email"])
result = await graph.ainvoke(
Command(resume={ids["check?"]: {"approved": True}}), config
)
assert (sorted(result["log"]), sent) == (["checked", "sent"], ["email"])