fix(dify-agent): preserve snapshots for failed and cancelled runs (#40876)

This commit is contained in:
盐粒 Yanli 2026-08-17 10:11:22 +00:00 committed by GitHub
parent 96809473fc
commit a679ee5644
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1575 additions and 440 deletions

View File

@ -23,6 +23,7 @@ from dify_agent.protocol import (
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RunCancelledEvent,
RunEvent,
RunStatusResponse,
)
@ -45,6 +46,15 @@ class AgentBackendRunClient(Protocol):
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Request explicit cancellation for one Agent backend run."""
def cancel_run_and_wait(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
"""Request cancellation and wait for runner cleanup to finish."""
def stream_events(
self,
run_id: str,
@ -67,6 +77,15 @@ class _DifyAgentSyncClient(Protocol):
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Cancel one run synchronously."""
def cancel_run_and_wait_sync(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
"""Cancel one run and wait for its terminal event synchronously."""
def stream_events_sync(
self,
run_id: str,
@ -110,6 +129,19 @@ class DifyAgentBackendRunClient:
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def cancel_run_and_wait(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
"""Cancel one run, then wait for the cleanup-complete terminal event."""
try:
return self.client.cancel_run_and_wait_sync(run_id, request=request, after=after)
except Exception as exc:
raise _normalize_dify_agent_error(exc) from exc
def stream_events(
self,
run_id: str,

View File

@ -99,6 +99,7 @@ class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
error: str
error_type: RunFailureType | None = None
reason: str | None = None
session_snapshot: CompositorSessionSnapshot | None = None
class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
@ -107,6 +108,7 @@ class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
type: Literal[AgentBackendInternalEventType.RUN_CANCELLED] = AgentBackendInternalEventType.RUN_CANCELLED
reason: str | None = None
message: str | None = None
session_snapshot: CompositorSessionSnapshot | None = None
type AgentBackendInternalEvent = Annotated[
@ -184,6 +186,7 @@ class AgentBackendRunEventAdapter:
error=event.data.error,
error_type=event.data.error_type,
reason=event.data.reason,
session_snapshot=event.data.session_snapshot,
)
]
case RunCancelledEvent():
@ -193,6 +196,7 @@ class AgentBackendRunEventAdapter:
source_event_id=event.id,
reason=event.data.reason,
message=event.data.message,
session_snapshot=event.data.session_snapshot,
)
]
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")

View File

@ -18,6 +18,8 @@ from dify_agent.protocol import (
CreateRunRequest,
CreateRunResponse,
DeferredToolCallPayload,
RunCancelledEvent,
RunCancelledEventData,
RunEvent,
RunFailedEvent,
RunFailedEventData,
@ -69,6 +71,28 @@ class FakeAgentBackendRunClient:
del request
return CancelRunResponse(run_id=run_id, status="cancelled")
def cancel_run_and_wait(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
"""Return a deterministic cleanup-complete cancellation event."""
del after
request = request or CancelRunRequest()
_ = self.cancel_run(run_id, request)
return RunCancelledEvent(
id="cancel-0",
run_id=run_id,
created_at=_FIXED_TIME,
data=RunCancelledEventData(
reason=request.reason,
message=request.message,
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
)
def stream_events(
self,
run_id: str,
@ -133,7 +157,11 @@ class FakeAgentBackendRunClient:
id="2-0",
run_id=run_id,
created_at=_FIXED_TIME,
data=RunFailedEventData(error="fake failure", reason="unit_test"),
data=RunFailedEventData(
error="fake failure",
reason="unit_test",
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
),
)
case FakeAgentBackendScenario.PAUSED:

View File

@ -24,6 +24,7 @@ from clients.agent_backend import (
AgentBackendDeferredToolCallInternalEvent,
AgentBackendError,
AgentBackendInternalEventType,
AgentBackendRunCancelledInternalEvent,
AgentBackendRunClient,
AgentBackendRunEventAdapter,
AgentBackendRunFailedError,
@ -681,6 +682,8 @@ class AgentAppRunner:
queue_manager=queue_manager,
model_name=model_name,
query=query,
session_scope=scope,
binding_id=runtime.binding_id,
)
if isinstance(terminal, AgentBackendDeferredToolCallInternalEvent):
@ -700,6 +703,15 @@ class AgentAppRunner:
)
return
if isinstance(terminal, AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent):
# None means no post-exit snapshot was produced; leave the previously stored session snapshot untouched.
if terminal.session_snapshot is not None:
self._save_session(
scope=scope,
binding_id=runtime.binding_id,
snapshot=terminal.session_snapshot,
)
if not isinstance(terminal, AgentBackendRunSucceededInternalEvent):
if isinstance(terminal, AgentBackendRunFailedInternalEvent):
reason = terminal.reason
@ -895,6 +907,8 @@ class AgentAppRunner:
queue_manager: AppQueueManager,
model_name: str,
query: str | None,
session_scope: AgentAppSessionScope,
binding_id: str,
):
"""Consume backend events while preserving raw recorder granularity."""
terminal = None
@ -904,6 +918,7 @@ class AgentAppRunner:
queue_manager=queue_manager,
)
text_delta_debouncer = _TextDeltaDebouncer(debounce_seconds=self._text_delta_debounce_seconds)
last_event_id: str | None = None
def persist_answer_text(content_delta: str) -> None:
try:
@ -934,14 +949,26 @@ class AgentAppRunner:
should_stop=queue_manager.is_stopped,
)
for public_event in public_events:
if public_event.id is not None:
last_event_id = public_event.id
if queue_manager.is_stopped():
flush_pending_agent_message_text()
self._cancel_run(run_id)
self._cancel_run(
run_id,
after=last_event_id,
session_scope=session_scope,
binding_id=binding_id,
)
raise GenerateTaskStoppedError()
for internal_event in self._event_adapter.adapt(public_event):
if queue_manager.is_stopped():
flush_pending_agent_message_text()
self._cancel_run(run_id)
self._cancel_run(
run_id,
after=last_event_id,
session_scope=session_scope,
binding_id=binding_id,
)
raise GenerateTaskStoppedError()
if internal_event.type in (
AgentBackendInternalEventType.RUN_STARTED,
@ -978,21 +1005,52 @@ class AgentAppRunner:
raise
except Exception as error:
flush_pending_agent_message_text()
self._cancel_run(run_id)
self._cancel_run(
run_id,
after=last_event_id,
session_scope=session_scope,
binding_id=binding_id,
)
if queue_manager.is_stopped():
raise GenerateTaskStoppedError() from error
raise
flush_pending_agent_message_text()
if queue_manager.is_stopped():
self._cancel_run(run_id)
self._cancel_run(
run_id,
after=last_event_id,
session_scope=session_scope,
binding_id=binding_id,
)
raise GenerateTaskStoppedError()
return terminal, process_recorder
def _cancel_run(self, run_id: str) -> None:
def _cancel_run(
self,
run_id: str,
*,
after: str | None,
session_scope: AgentAppSessionScope,
binding_id: str,
) -> None:
try:
self._agent_backend_client.cancel_run(run_id)
public_event = self._agent_backend_client.cancel_run_and_wait(run_id, after=after)
for internal_event in self._event_adapter.adapt(public_event):
if (
isinstance(internal_event, AgentBackendRunCancelledInternalEvent)
and internal_event.session_snapshot is not None
):
self._save_session(
scope=session_scope,
binding_id=binding_id,
snapshot=internal_event.session_snapshot,
)
except Exception:
logger.warning("Failed to cancel stopped Agent App backend run: run_id=%s", run_id, exc_info=True)
logger.warning(
"Failed to finish cancelling stopped Agent App backend run: run_id=%s",
run_id,
exc_info=True,
)
def _publish_answer(
self, *, queue_manager: AppQueueManager, model_name: str, answer: str, query: str | None

View File

@ -340,6 +340,17 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
process_data=process_data,
metadata=metadata,
)
# None means no post-exit snapshot was produced; leave the previously stored session snapshot untouched.
if (
isinstance(terminal_event, AgentBackendRunFailedInternalEvent | AgentBackendRunCancelledInternalEvent)
and terminal_event.session_snapshot is not None
):
self._save_session_snapshot(
session_scope=session_scope,
binding_id=stored_session.binding_id,
snapshot=terminal_event.session_snapshot,
metadata=metadata,
)
if exhausted is not None:
# Streaming error / unexpected end — surface immediately without
# retrying because the failure is transport-level.
@ -516,16 +527,20 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
- ``terminal_event``: the first non-stream/non-started internal event,
or ``None`` if the stream ended without one.
- ``transport_failure``: a populated ``StreamCompletedEvent`` when the
stream itself errored (backend/HTTP/protocol fault). Mutually
exclusive with ``terminal_event``.
stream itself errored (backend/HTTP/protocol fault). A cancellation
terminal may accompany it so the caller can persist the final session
snapshot while preserving the original transport failure.
"""
stream_event_count = 0
last_event_id: str | None = None
try:
for public_event in self._agent_backend_client.stream_events(
run_id,
should_stop=self._is_graph_aborted,
):
stream_event_count += 1
if public_event.id is not None:
last_event_id = public_event.id
for internal_event in self._event_adapter.adapt(public_event):
if internal_event.type == AgentBackendInternalEventType.RUN_STARTED:
continue
@ -552,8 +567,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
| AgentBackendDeferredToolCallInternalEvent,
):
return internal_event, None
self._cancel_backend_run(run_id, reason="unexpected_event")
return None, self._failure_event(
cancellation = self._cancel_backend_run(
run_id,
reason="unexpected_event",
after=last_event_id,
)
return cancellation, self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
@ -561,8 +580,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
error_type="agent_backend_stream_error",
)
except AgentBackendError as error:
self._cancel_backend_run(run_id, reason=self._stream_stop_reason())
return None, self._failure_event(
cancellation = self._cancel_backend_run(
run_id,
reason=self._stream_stop_reason(),
after=last_event_id,
)
return cancellation, self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
@ -570,8 +593,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
error_type=self._agent_backend_error_type(error),
)
except Exception as error:
self._cancel_backend_run(run_id, reason=self._stream_stop_reason())
return None, self._failure_event(
cancellation = self._cancel_backend_run(
run_id,
reason=self._stream_stop_reason(),
after=last_event_id,
)
return cancellation, self._failure_event(
inputs=inputs,
process_data=process_data,
metadata=metadata,
@ -579,8 +606,12 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
error_type="agent_backend_stream_error",
)
self._cancel_backend_run(run_id, reason="stream_ended_without_terminal_event")
return None, None
cancellation = self._cancel_backend_run(
run_id,
reason=self._stream_stop_reason() if self._is_graph_aborted() else "stream_ended_without_terminal_event",
after=last_event_id,
)
return cancellation, None
def _is_graph_aborted(self) -> bool:
"""Let Agent SSE consumption observe GraphEngine's cooperative abort state."""
@ -592,14 +623,25 @@ class DifyAgentNode(Node[DifyAgentNodeData]):
def _stream_stop_reason(self) -> str:
return "workflow_graph_aborted" if self._is_graph_aborted() else "event_stream_failed"
def _cancel_backend_run(self, run_id: str, *, reason: str) -> None:
def _cancel_backend_run(
self,
run_id: str,
*,
reason: str,
after: str | None,
) -> AgentBackendRunCancelledInternalEvent | None:
try:
self._agent_backend_client.cancel_run(
public_event = self._agent_backend_client.cancel_run_and_wait(
run_id,
CancelRunRequest(reason=reason, message="Workflow Agent event consumption stopped"),
after=after,
)
for internal_event in self._event_adapter.adapt(public_event):
if isinstance(internal_event, AgentBackendRunCancelledInternalEvent):
return internal_event
except Exception:
logger.warning("Failed to cancel Workflow Agent backend run: run_id=%s", run_id, exc_info=True)
logger.warning("Failed to finish cancelling Workflow Agent backend run: run_id=%s", run_id, exc_info=True)
return None
@staticmethod
def _record_type_check_metadata(metadata: dict[str, Any], outcome: OutputTypeCheckOutcome) -> None:

View File

@ -9,6 +9,8 @@ from dify_agent.protocol import (
CancelRunResponse,
CreateRunRequest,
CreateRunResponse,
RunCancelledEvent,
RunCancelledEventData,
RunEvent,
RunStartedEvent,
RunStatusResponse,
@ -51,6 +53,7 @@ def _request() -> CreateRunRequest:
class _SuccessfulClient:
stream_options: tuple[int | None, object, Callable[[], bool] | None] | None = None
cancel_after: str | None = None
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
assert isinstance(request, CreateRunRequest)
@ -60,6 +63,21 @@ class _SuccessfulClient:
del request
return CancelRunResponse(run_id=run_id, status="cancelled")
def cancel_run_and_wait_sync(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
self.cancel_after = after
request = request or CancelRunRequest()
return RunCancelledEvent(
id="2-0",
run_id=run_id,
data=RunCancelledEventData(reason=request.reason, message=request.message),
)
def stream_events_sync(
self,
run_id: str,
@ -94,13 +112,20 @@ def test_dify_agent_backend_run_client_delegates_sync_methods() -> None:
created = client.create_run(_request())
cancelled = client.cancel_run(created.run_id)
cancelled_event = client.cancel_run_and_wait(
created.run_id,
CancelRunRequest(reason="stopped"),
after="1-0",
)
events = list(client.stream_events(created.run_id, should_stop=should_stop))
status = client.wait_run(created.run_id)
assert created.run_id == "run-1"
assert cancelled.status == "cancelled"
assert cancelled_event.data.reason == "stopped"
assert events[0].type == "run_started"
assert status.status == "succeeded"
assert wrapped.cancel_after == "1-0"
assert wrapped.stream_options == (2, _STREAM_TIMEOUT_UNSET, should_stop)

View File

@ -18,6 +18,8 @@ from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
PydanticAIStreamRunEvent,
RunCancelledEvent,
RunCancelledEventData,
RunEvent,
RunFailedEvent,
RunFailedEventData,
@ -39,6 +41,7 @@ from sqlalchemy import event, select
from sqlalchemy.orm import Session
from clients.agent_backend import (
AgentBackendError,
AgentBackendRunEventAdapter,
AgentBackendRunFailedError,
AgentBackendRunFailedInternalEvent,
@ -112,12 +115,38 @@ class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.cancelled_run_ids: list[str] = []
self.cancel_after: list[str | None] = []
@override
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
self.cancelled_run_ids.append(run_id)
return super().cancel_run(run_id, request=request)
@override
def cancel_run_and_wait(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
self.cancel_after.append(after)
return super().cancel_run_and_wait(run_id, request=request, after=after)
class _CancelAndWaitFailingClient(_RecordingFakeAgentBackendRunClient):
@override
def cancel_run_and_wait(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
del request
self.cancel_after.append(after)
raise RuntimeError(f"failed to finish cancelling {run_id}")
class _RunLimitBindingLostFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
@ -143,6 +172,38 @@ class _RunLimitBindingLostFakeAgentBackendRunClient(FakeAgentBackendRunClient):
)
class _TerminalWithoutSnapshotFakeAgentBackendRunClient(FakeAgentBackendRunClient):
def __init__(self, *, terminal_type: str) -> None:
super().__init__()
self.terminal_type = terminal_type
@override
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after, should_stop
created_at = datetime(2026, 1, 1, tzinfo=UTC)
yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at)
if self.terminal_type == "failed":
yield RunFailedEvent(
id="2-0",
run_id=run_id,
created_at=created_at,
data=RunFailedEventData(error="failed without snapshot"),
)
else:
yield RunCancelledEvent(
id="2-0",
run_id=run_id,
created_at=created_at,
data=RunCancelledEventData(reason="cancelled without snapshot"),
)
class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient):
@override
def stream_events(
@ -443,6 +504,26 @@ class _FakeSessionStore:
self.saved.append((scope, binding_id, snapshot, pending_form_id, pending_tool_call_id))
class _ExplodingSessionStore(_FakeSessionStore):
def __init__(self, loaded: CompositorSessionSnapshot | None = None) -> None:
super().__init__(loaded=loaded)
self.save_attempts: list[CompositorSessionSnapshot | None] = []
@override
def save_active_snapshot(
self,
*,
scope: AgentAppSessionScope,
binding_id: str,
snapshot: CompositorSessionSnapshot | None,
pending_form_id: str | None = None,
pending_tool_call_id: str | None = None,
) -> None:
del scope, binding_id, pending_form_id, pending_tool_call_id
self.save_attempts.append(snapshot)
raise RuntimeError("session save failed")
class _MonotonicClock:
def __init__(self, *values: float) -> None:
self._values = list(values)
@ -772,6 +853,7 @@ def test_streaming_turn_cancels_after_persisting_seen_agent_answer(
assert len(rows) == 1
assert rows[0].answer == "hello "
assert client.cancelled_run_ids == ["fake-run-1"]
assert client.cancel_after == ["3-0"]
def test_tool_result_without_identity_does_not_attach_to_previous_tool(
@ -1162,8 +1244,40 @@ def test_failed_run_raises_agent_backend_error() -> None:
with pytest.raises(AgentBackendRunFailedError, match="fake failure .*agent_run_id=fake-run-1"):
_run(_runner(client, store), qm)
# No message-end on failure; no snapshot saved.
# No message-end on failure; post-exit session state is still saved.
assert not [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
assert store.saved[0][2] == CompositorSessionSnapshot(layers=[])
@pytest.mark.parametrize("outcome", ["failed", "stopped"])
def test_snapshot_save_failure_preserves_original_app_outcome(outcome: str) -> None:
store = _ExplodingSessionStore()
queue_manager: _FakeQueueManager = _FakeQueueManager() if outcome == "failed" else _StoppedQueueManager()
client = FakeAgentBackendRunClient(
scenario=FakeAgentBackendScenario.FAILED if outcome == "failed" else FakeAgentBackendScenario.SUCCESS
)
expected_error = AgentBackendRunFailedError if outcome == "failed" else GenerateTaskStoppedError
with pytest.raises(expected_error, match="fake failure" if outcome == "failed" else None):
_run(_runner(client, store), queue_manager)
assert store.save_attempts == [CompositorSessionSnapshot(layers=[])]
@pytest.mark.parametrize(
("terminal_type", "expected_error"),
[("failed", AgentBackendRunFailedError), ("cancelled", AgentBackendError)],
)
def test_terminal_without_snapshot_preserves_prior_app_session_without_write(
terminal_type: str,
expected_error: type[Exception],
) -> None:
store = _FakeSessionStore()
client = _TerminalWithoutSnapshotFakeAgentBackendRunClient(terminal_type=terminal_type)
with pytest.raises(expected_error):
_run(_runner(client, store), _FakeQueueManager())
assert store.saved == []
@ -1233,7 +1347,7 @@ def test_agent_backend_failure_to_exception_prefers_run_failure_type_over_known_
}
def test_stopped_task_cancels_agent_backend_run_and_skips_session_save() -> None:
def test_stopped_task_waits_for_cancelled_snapshot_and_saves_session() -> None:
client = _RecordingFakeAgentBackendRunClient()
store = _FakeSessionStore()
qm = _StoppedQueueManager()
@ -1242,6 +1356,18 @@ def test_stopped_task_cancels_agent_backend_run_and_skips_session_save() -> None
_run(_runner(client, store), qm)
assert client.cancelled_run_ids == ["fake-run-1"]
assert len(store.saved) == 1
assert store.saved[0][2] == CompositorSessionSnapshot(layers=[])
def test_cancel_and_wait_failure_preserves_stopped_app_outcome() -> None:
client = _CancelAndWaitFailingClient()
store = _FakeSessionStore()
with pytest.raises(GenerateTaskStoppedError):
_run(_runner(client, store), _StoppedQueueManager())
assert client.cancel_after == [None]
assert store.saved == []

View File

@ -12,7 +12,11 @@ from dify_agent.protocol import (
CancelRunRequest,
CancelRunResponse,
PydanticAIStreamRunEvent,
RunCancelledEvent,
RunCancelledEventData,
RunEvent,
RunFailedEvent,
RunFailedEventData,
RunStartedEvent,
RunSucceededEvent,
RunSucceededEventData,
@ -21,6 +25,7 @@ from pydantic_ai.messages import PartDeltaEvent, TextPartDelta
from clients.agent_backend import (
AgentBackendInternalEventType,
AgentBackendRunCancelledInternalEvent,
AgentBackendRunEventAdapter,
AgentBackendStreamError,
AgentBackendStreamInternalEvent,
@ -205,6 +210,25 @@ class FakeSessionStore:
self.saved.append((scope, binding_id, snapshot, pending_form_id, pending_tool_call_id))
class ExplodingSessionStore(FakeSessionStore):
def __init__(self, snapshot: CompositorSessionSnapshot | None = None) -> None:
super().__init__(snapshot=snapshot)
self.save_attempts: list[CompositorSessionSnapshot | None] = []
def save_active_snapshot(
self,
*,
scope: WorkflowAgentSessionScope,
binding_id: str,
snapshot: CompositorSessionSnapshot | None,
pending_form_id: str | None = None,
pending_tool_call_id: str | None = None,
) -> None:
del scope, binding_id, pending_form_id, pending_tool_call_id
self.save_attempts.append(snapshot)
raise RuntimeError("simulated DB failure")
class FileOutputBackendClient(FakeAgentBackendRunClient):
output_payload: dict[str, object]
@ -259,6 +283,7 @@ class FailingStreamBackendClient(FakeAgentBackendRunClient):
def __init__(self) -> None:
super().__init__()
self.cancel_requests: list[CancelRunRequest | None] = []
self.cancel_after: list[str | None] = []
def stream_events(
self,
@ -275,6 +300,50 @@ class FailingStreamBackendClient(FakeAgentBackendRunClient):
self.cancel_requests.append(request)
return CancelRunResponse(run_id=run_id, status="cancelled")
def cancel_run_and_wait(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
self.cancel_after.append(after)
return super().cancel_run_and_wait(run_id, request=request, after=after)
class FailingAfterStartedStreamBackendClient(FailingStreamBackendClient):
def stream_events(
self,
run_id: str,
*,
after: str | None = None,
should_stop: Callable[[], bool] | None = None,
) -> Iterator[RunEvent]:
del after, should_stop
yield RunStartedEvent(id="cursor-1", run_id=run_id)
raise AgentBackendStreamError("stream failed after started")
class TerminalWithoutSnapshotBackendClient(FakeAgentBackendRunClient):
def __init__(self, *, terminal_type: str) -> None:
super().__init__()
self.terminal_type = terminal_type
def _events(self, run_id: str):
if self.terminal_type == "failed":
terminal: RunEvent = RunFailedEvent(
id="2-0",
run_id=run_id,
data=RunFailedEventData(error="failed without snapshot"),
)
else:
terminal = RunCancelledEvent(
id="2-0",
run_id=run_id,
data=RunCancelledEventData(reason="cancelled without snapshot"),
)
return (RunStartedEvent(id="1-0", run_id=run_id), terminal)
class EmptyStreamBackendClient(FailingStreamBackendClient):
def stream_events(
@ -601,13 +670,15 @@ def test_agent_node_run_normalizes_declared_array_file_output_with_canonical_map
def test_agent_node_run_maps_failed_agent_backend_run_to_node_result():
events = list(_node(scenario=FakeAgentBackendScenario.FAILED)._run())
store = FakeSessionStore()
events = list(_node(scenario=FakeAgentBackendScenario.FAILED, session_store=store)._run())
assert len(events) == 1
result = cast(StreamCompletedEvent, events[0]).node_run_result
assert result.status == WorkflowNodeExecutionStatus.FAILED
assert result.error == "fake failure"
assert result.error_type == "unit_test"
assert store.saved[0][2] == CompositorSessionSnapshot(layers=[])
def test_agent_node_saves_success_snapshot_and_reuses_existing_snapshot():
@ -636,12 +707,7 @@ def test_agent_node_run_when_session_store_save_raises_records_persist_error_in_
``session_snapshot_persist_error`` in the agent_backend metadata so the
incident is observable from the workflow_node_executions record."""
class _ExplodingSessionStore(FakeSessionStore):
def save_active_snapshot(self, **kwargs): # type: ignore[override]
del kwargs
raise RuntimeError("simulated DB failure")
store = _ExplodingSessionStore()
store = ExplodingSessionStore()
events = list(_node(session_store=store)._run())
assert len(events) == 1
@ -652,6 +718,46 @@ def test_agent_node_run_when_session_store_save_raises_records_persist_error_in_
assert agent_backend["session_snapshot_persist_error"] == "workflow_agent_workspace_store_error"
@pytest.mark.parametrize("failure_kind", ["backend", "transport"])
def test_agent_node_snapshot_save_failure_preserves_original_failure(failure_kind: str) -> None:
store = ExplodingSessionStore()
client = (
FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED)
if failure_kind == "backend"
else FailingStreamBackendClient()
)
events = list(_node(agent_backend_client=client, session_store=store)._run())
result = cast(StreamCompletedEvent, events[0]).node_run_result
assert result.status == WorkflowNodeExecutionStatus.FAILED
if failure_kind == "backend":
assert (result.error, result.error_type) == ("fake failure", "unit_test")
else:
assert result.error == "stream reconnect attempts exhausted"
assert result.error_type == "agent_backend_stream_error"
agent_backend = result.metadata[WorkflowNodeExecutionMetadataKey.AGENT_LOG]["agent_backend"]
assert agent_backend["session_snapshot_persisted"] is False
assert agent_backend["session_snapshot_persist_error"] == "workflow_agent_workspace_store_error"
assert store.save_attempts == [CompositorSessionSnapshot(layers=[])]
@pytest.mark.parametrize("terminal_type", ["failed", "cancelled"])
def test_agent_node_terminal_without_snapshot_preserves_prior_session_without_write(terminal_type: str) -> None:
store = FakeSessionStore()
events = list(
_node(
agent_backend_client=TerminalWithoutSnapshotBackendClient(terminal_type=terminal_type),
session_store=store,
)._run()
)
result = cast(StreamCompletedEvent, events[0]).node_run_result
assert result.status == WorkflowNodeExecutionStatus.FAILED
assert store.saved == []
def test_agent_node_paused_run_requests_workflow_pause_and_persists_snapshot():
store = FakeSessionStore()
node = _node(scenario=FakeAgentBackendScenario.PAUSED, session_store=store)
@ -819,7 +925,7 @@ def test_agent_node_cancels_backend_run_when_stream_fails():
metadata={"agent_backend": {}},
)
assert terminal is None
assert isinstance(terminal, AgentBackendRunCancelledInternalEvent)
assert failure is not None
assert failure.node_run_result.process_data == {"workflow_agent_binding_id": "binding-1"}
assert len(client.cancel_requests) == 1
@ -827,6 +933,23 @@ def test_agent_node_cancels_backend_run_when_stream_fails():
assert client.cancel_requests[0].reason == "event_stream_failed"
def test_agent_node_forwards_last_stream_cursor_when_cancelling_after_failure() -> None:
client = FailingAfterStartedStreamBackendClient()
node = _node(agent_backend_client=client)
terminal, failure = node._consume_event_stream(
"run-1",
inputs={},
process_data={"workflow_agent_binding_id": "binding-1"},
metadata={"agent_backend": {}},
)
assert isinstance(terminal, AgentBackendRunCancelledInternalEvent)
assert failure is not None
assert failure.node_run_result.error == "stream failed after started"
assert client.cancel_after == ["cursor-1"]
def test_agent_node_cancels_backend_run_when_stream_ends_without_terminal_event():
client = EmptyStreamBackendClient()
node = _node(agent_backend_client=client)
@ -838,7 +961,7 @@ def test_agent_node_cancels_backend_run_when_stream_ends_without_terminal_event(
metadata={"agent_backend": {}},
)
assert terminal is None
assert isinstance(terminal, AgentBackendRunCancelledInternalEvent)
assert failure is None
assert client.cancel_requests[0] is not None
assert client.cancel_requests[0].reason == "stream_ended_without_terminal_event"
@ -855,7 +978,7 @@ def test_agent_node_cancels_backend_run_when_stream_raises_unexpected_error():
metadata={"agent_backend": {}},
)
assert terminal is None
assert isinstance(terminal, AgentBackendRunCancelledInternalEvent)
assert failure is not None
assert failure.node_run_result.error == "unexpected stream failure"
assert failure.node_run_result.process_data == {"workflow_agent_binding_id": "binding-1"}
@ -877,16 +1000,18 @@ def test_agent_node_uses_graph_abort_reason_when_cancel_request_fails(caplog):
assert terminal is None
assert failure is not None
assert failure.node_run_result.error == "stream reconnect attempts exhausted"
assert failure.node_run_result.error_type == "agent_backend_stream_error"
assert client.cancel_requests[0] is not None
assert client.cancel_requests[0].reason == "workflow_graph_aborted"
assert "Failed to cancel Workflow Agent backend run" in caplog.text
assert "Failed to finish cancelling Workflow Agent backend run" in caplog.text
def test_agent_node_cancels_backend_run_for_unexpected_internal_event():
client = FakeAgentBackendRunClient()
node = _node(agent_backend_client=client)
node._agent_backend_client.cancel_run = MagicMock( # type: ignore[method-assign]
return_value=CancelRunResponse(run_id="run-1", status="cancelled")
node._agent_backend_client.cancel_run_and_wait = MagicMock( # type: ignore[method-assign]
return_value=RunCancelledEvent(run_id="run-1")
)
node._event_adapter.adapt = MagicMock( # type: ignore[method-assign]
return_value=[SimpleNamespace(type=AgentBackendInternalEventType.RUN_FAILED)]
@ -905,7 +1030,7 @@ def test_agent_node_cancels_backend_run_for_unexpected_internal_event():
"Unexpected internal event type <AgentBackendInternalEventType.RUN_FAILED: 'run_failed'>"
)
assert failure.node_run_result.process_data == {"workflow_agent_binding_id": "binding-1"}
node._agent_backend_client.cancel_run.assert_called_once()
node._agent_backend_client.cancel_run_and_wait.assert_called_once()
def test_agent_node_records_stream_usage_metadata():

View File

@ -76,8 +76,9 @@ current run. Callers control whether each layer is suspended or deleted through
`CreateRunRequest.on_exit`.
Exit signals control the **layer lifecycle state**, not the execution state of an
`agent run`. The default policy is `suspend`, so a successful `agent run` returns
a reusable `session_snapshot`.
`agent run`. The default policy is `suspend`, so any run that enters and exits its
compositor context can return a reusable `session_snapshot`, including failed or
cancelled runs. Failures or cancellations before entry have no new snapshot.
### Default: suspend layers

View File

@ -279,26 +279,29 @@ run as failed with `error_type: "agent_run_limit_exceeded"`.
During FastAPI shutdown the scheduler rejects new runs, waits up to
`DIFY_AGENT_SHUTDOWN_GRACE_SECONDS` for active tasks, then cancels remaining tasks
and attempts to finalize them as failed. Success, failure, cancellation, and this
shutdown path all use one atomic Redis transition: only the first transition from
`running` appends a terminal event and updates the run record. A later terminal
attempt leaves both the record and event stream unchanged. A hard process crash
can still leave active runs stuck as `running`; there is no in-service recovery
or worker handoff.
and attempts to finalize them as failed. Success and failure use an atomic Redis
transition. Cancellation first atomically records a private intent; after the
owner exits the runner, a second atomic transition appends `run_cancelled`,
updates the run record, and deletes the intent. The first accepted success,
failure, or cancellation intent wins. A hard process crash can still leave
active runs, including runs with accepted cancellation intent, stuck as
`running`; there is no in-service recovery or worker handoff.
Horizontal scaling is possible by running multiple API processes against the same
Redis prefix, but each process executes only the runs it accepted. Redis provides
shared status/event visibility, not load balancing or queued-job recovery. The
cancel endpoint can atomically accept a running run on any process. The process
that owns the runner observes the shared `run_cancelled` event, then cancels and
cleans up its local task. The HTTP response confirms that logical cancellation is
durable; local runner cleanup may still be in progress. Retrying a cancellation
after the run is already `cancelled` is idempotent.
that owns the runner observes the private cancellation-intent stream, cancels
and cleans up its local task, and only then emits `run_cancelled`. The HTTP
response confirms that cancellation intent is durable; `GET /runs/{run_id}` may
still report `running` until cleanup finishes. Retrying an accepted or completed
cancellation is idempotent.
Atomic terminal finalization currently assumes the configured Redis URL targets
one Redis deployment that can execute both run keys in a Lua script. The existing
record and event key names are unchanged and do not contain a shared Redis
Cluster hash tag, so Redis Cluster is not supported for this transition. During
one Redis deployment that can execute all run-coordination keys in a Lua script.
The record and event key names are unchanged, and cancellation adds a private
cancel-intent key. These keys do not contain a shared Redis Cluster hash tag, so
Redis Cluster is not supported for this transition. During
a rolling upgrade, older processes can still use the former split event/status
writes; treat the single-terminal invariant as active only after those processes
have exited. Deploy atomic terminal finalization everywhere first, then ensure
@ -336,8 +339,10 @@ effective prompts are rejected during create-run validation before the run is
persisted or scheduled.
There is no Pydantic AI history layer. To resume Agenton layer state, pass the
`session_snapshot` from a previous `run_succeeded.data` payload together with a
composition that has the same layer names and order.
`session_snapshot` from a previous terminal event together with a composition
that has the same layer names and order. Success always contains a snapshot.
Failure and cancellation contain one only when compositor entry succeeded and
layer exit completed; otherwise callers should retain their previous snapshot.
## Observing runs
@ -349,8 +354,11 @@ progress:
Failed records can also expose a stable machine-readable `error_type` alongside
the diagnostic `error` text.
- `POST /runs/{run_id}/cancel` atomically accepts cancellation on any API process
and emits `run_cancelled`; it returns `409` only when a success/failure terminal
already won. Runner cleanup continues asynchronously on the owner process.
and returns immediately. `CancelRunResponse.status == "cancelled"` acknowledges
a durably accepted cancellation intent, not completed runner cleanup. Callers
that require cleanup-complete state or its session snapshot must await the
public `run_cancelled` event or use `cancel_run_and_wait`. The endpoint returns
`409` only when a success/failure terminal already won.
- `GET /runs/{run_id}/events` polls the Redis Stream event log with `after` and
`next_cursor` cursors.
- `GET /runs/{run_id}/events/sse` replays and streams events over SSE. The SSE
@ -366,12 +374,15 @@ end with `run_cancelled`. Each run can append at most one of these terminal
events. Event envelopes retain `id`, `run_id`, `type`, `data`, and `created_at`;
`data` is typed per event type,
including Pydantic AI's `AgentStreamEvent` payload for `pydantic_ai_event` and a
terminal `run_succeeded.data` object containing a `CompositorSessionSnapshot` for
resumption. A successful run has exactly one active result branch: JSON-safe
terminal event may contain a `CompositorSessionSnapshot` for resumption.
`run_succeeded` always contains it; `run_failed` and `run_cancelled` contain it
only when compositor entry succeeded, layer exit completed, and a post-exit
snapshot was actually produced. A successful run has exactly one active result branch: JSON-safe
`output` for final answers, or `deferred_tool_call` when a layer such as
`dify.ask_human` ends the current agent run with an external deferred tool call.
Failed event payloads contain the diagnostic `error`, optional source-specific
`reason`, and optional stable `error_type`. Pydantic AI request/step budget
`reason`, optional stable `error_type`, and optional `session_snapshot`.
Cancelled payloads likewise may contain `session_snapshot`. Pydantic AI request/step budget
exhaustion enforced by Dify Agent is reported as
`error_type: "agent_run_limit_exceeded"`; consumers should branch on that value
rather than parsing the error text. The Dify Agent-owned wall-clock run deadline

View File

@ -43,6 +43,7 @@ from dify_agent.protocol import (
DestroyExecutionBindingRequest,
HomeSnapshotResponse,
RUN_EVENT_ADAPTER,
RunCancelledEvent,
RunEvent,
RunEventsResponse,
RunStatusResponse,
@ -384,8 +385,8 @@ class Client:
async def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
"""Request explicit cancellation for ``run_id``.
Acceptance atomically persists the cancelled state. The process executing
the run observes that state and performs runner cleanup asynchronously.
Acceptance atomically persists cancellation intent. The process executing
the run publishes ``run_cancelled`` after runner cleanup completes.
"""
request_model = request or CancelRunRequest()
try:
@ -417,6 +418,44 @@ class Client:
raise DifyAgentClientError(f"cancel_run_sync request failed: {exc}") from exc
return _parse_model_response(response, CancelRunResponse)
async def cancel_run_and_wait(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
"""Request cancellation and wait for its public terminal event."""
_ = await self.cancel_run(run_id, request)
resume_after = after
if after is not None and (await self.get_run(run_id)).status == "cancelled":
resume_after = None
async for event in self.stream_events(run_id, after=resume_after):
if isinstance(event, RunCancelledEvent):
return event
if event.type in _TERMINAL_EVENT_TYPES:
raise DifyAgentClientError(f"run {run_id!r} finished with {event.type!r} before cancellation")
raise DifyAgentStreamError(f"run {run_id!r} stream ended before run_cancelled")
def cancel_run_and_wait_sync(
self,
run_id: str,
request: CancelRunRequest | None = None,
*,
after: str | None = None,
) -> RunCancelledEvent:
"""Synchronous variant of ``cancel_run_and_wait``."""
_ = self.cancel_run_sync(run_id, request)
resume_after = after
if after is not None and self.get_run_sync(run_id).status == "cancelled":
resume_after = None
for event in self.stream_events_sync(run_id, after=resume_after):
if isinstance(event, RunCancelledEvent):
return event
if event.type in _TERMINAL_EVENT_TYPES:
raise DifyAgentClientError(f"run {run_id!r} finished with {event.type!r} before cancellation")
raise DifyAgentStreamError(f"run {run_id!r} stream ended before run_cancelled")
async def get_run(self, run_id: str) -> RunStatusResponse:
"""Return the current status for ``run_id`` or raise a mapped client error."""
try:

View File

@ -21,9 +21,10 @@ by ``DIFY_AGENT_MODEL_LAYER_ID``, the optional history layer named by
``DIFY_AGENT_HISTORY_LAYER_ID``, and the optional structured output layer named
by ``DIFY_AGENT_OUTPUT_LAYER_ID``. Request-level ``on_exit`` signals decide
whether each active layer is suspended or deleted when the run exits, with
suspend as the default so successful terminal events can include resumable
snapshots. Successful runs always publish the resumable Agenton session snapshot
on the terminal ``run_succeeded`` event together with either the final JSON-safe
suspend as the default so terminal events can include resumable snapshots.
Successful runs always publish the resumable Agenton session snapshot on the
terminal ``run_succeeded`` event; failed and cancelled runs publish it when the
compositor context was entered and exited. Success includes either the final JSON-safe
``output`` or a deferred external ``deferred_tool_call`` payload. Session
snapshots carry only layer lifecycle/runtime state in
compositor order; they do not persist output-layer config. Resumed
@ -329,6 +330,7 @@ class RunFailedEventData(BaseModel):
error: str
error_type: RunFailureType | None = None
reason: str | None = None
session_snapshot: CompositorSessionSnapshot | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@ -338,6 +340,7 @@ class RunCancelledEventData(BaseModel):
reason: str | None = None
message: str | None = None
session_snapshot: CompositorSessionSnapshot | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")

View File

@ -0,0 +1,19 @@
"""Private cancellation coordination types shared by schedulers and run stores."""
from datetime import datetime
from typing import ClassVar
from pydantic import BaseModel, ConfigDict
class RunCancellationIntent(BaseModel):
"""The first accepted request to cancel one running run."""
reason: str | None = None
message: str | None = None
requested_at: datetime
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
__all__ = ["RunCancellationIntent"]

View File

@ -1,9 +1,10 @@
"""Event sink contracts used by the runner and storage adapters.
Non-terminal events remain append-only. Terminal events use ``finalize_run`` so
the event and matching run status are committed as one compare-and-set
transition. Tests can use ``InMemoryRunEventSink`` without Redis; production
storage implements the same contract with Redis streams in
Non-terminal events remain append-only. Successful and failed terminal events
use ``finalize_run`` so the event and matching run status are committed as one
compare-and-set transition. Cancellation has a dedicated intent-aware finalizer.
Tests can use ``InMemoryRunEventSink`` without Redis; production storage
implements the same contract with Redis streams in
``dify_agent.storage.redis_run_store``.
"""
@ -21,8 +22,6 @@ from dify_agent.protocol.schemas import (
EmptyRunEventData,
PydanticAIStreamRunEvent,
RunEvent,
RunCancelledEvent,
RunCancelledEventData,
RunFailedEvent,
RunFailedEventData,
RunFailureType,
@ -35,7 +34,7 @@ from dify_agent.protocol.schemas import (
_UNSET = object()
TerminalRunEvent: TypeAlias = RunSucceededEvent | RunFailedEvent | RunCancelledEvent
TerminalRunEvent: TypeAlias = RunSucceededEvent | RunFailedEvent
NonTerminalRunEvent: TypeAlias = RunStartedEvent | PydanticAIStreamRunEvent
@ -105,8 +104,6 @@ def terminal_event_status_fields(
return "succeeded", None, None
case RunFailedEvent():
return "failed", event.data.error, event.data.error_type
case RunCancelledEvent():
return "cancelled", event.data.message or event.data.reason, None
async def emit_run_event(
@ -188,29 +185,18 @@ async def emit_run_failed(
error: str,
error_type: RunFailureType | None = None,
reason: str | None = None,
session_snapshot: CompositorSessionSnapshot | None = None,
) -> RunFinalizationResult:
"""Finalize a run with a failed terminal event."""
return await sink.finalize_run(
RunFailedEvent(
run_id=run_id,
data=RunFailedEventData(error=error, error_type=error_type, reason=reason),
created_at=utc_now(),
),
)
async def emit_run_cancelled(
sink: RunEventSink,
*,
run_id: str,
reason: str | None = None,
message: str | None = None,
) -> RunFinalizationResult:
"""Finalize a run with a cancelled terminal event."""
return await sink.finalize_run(
RunCancelledEvent(
run_id=run_id,
data=RunCancelledEventData(reason=reason, message=message),
data=RunFailedEventData(
error=error,
error_type=error_type,
reason=reason,
session_snapshot=session_snapshot,
),
created_at=utc_now(),
),
)
@ -223,7 +209,6 @@ __all__ = [
"RunFinalizationResult",
"TerminalRunEvent",
"emit_pydantic_ai_event",
"emit_run_cancelled",
"emit_run_event",
"emit_run_failed",
"emit_run_started",

View File

@ -20,10 +20,11 @@ from typing import Protocol
import httpx
from agenton.compositor import LayerProviderInput
from dify_agent.protocol.schemas import CancelRunRequest, CancelRunResponse, CreateRunRequest
from agenton.compositor import CompositorSessionSnapshot, LayerProviderInput
from dify_agent.protocol.schemas import CancelRunRequest, CancelRunResponse, CreateRunRequest, RunStatus
from dify_agent.runtime.cancellation import RunCancellationIntent
from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.event_sink import RunEventSink, emit_run_cancelled, emit_run_failed
from dify_agent.runtime.event_sink import RunEventSink, RunFinalizationResult, emit_run_failed
from dify_agent.runtime.runner import DEFAULT_AGENT_RUN_TIMEOUT_SECONDS, AgentRunRunner
from dify_agent.server.schemas import RunRecord
@ -45,14 +46,37 @@ class RunStore(RunEventSink, Protocol):
"""Persist a new run record and return it with status ``running``."""
...
async def wait_for_cancellation(self, run_id: str) -> bool:
"""Wait for a terminal state and report whether cancellation won."""
async def request_cancellation(self, run_id: str, request: CancelRunRequest) -> RunStatus:
"""Persist the first cancellation intent and return the current status."""
...
async def get_cancellation_intent(self, run_id: str) -> RunCancellationIntent | None:
"""Return the accepted cancellation intent, if one exists."""
...
async def wait_for_cancellation(self, run_id: str) -> RunCancellationIntent | None:
"""Wait for a cancellation intent or a different terminal state."""
...
async def finalize_cancellation(
self,
run_id: str,
intent: RunCancellationIntent,
*,
session_snapshot: CompositorSessionSnapshot | None = None,
) -> RunFinalizationResult:
"""Publish cancellation after the owner runner has exited."""
...
class RunnableRun(Protocol):
"""Executable unit for one scheduled run."""
@property
def terminal_session_snapshot(self) -> CompositorSessionSnapshot | None:
"""Return the post-exit snapshot for the current invocation, if available."""
...
async def run(self) -> None:
"""Run until terminal status/events have been written or cancellation occurs."""
...
@ -125,14 +149,9 @@ class RunScheduler:
async def cancel_run(self, run_id: str, request: CancelRunRequest) -> CancelRunResponse:
"""Persist an idempotent cancellation without relying on local task ownership."""
finalization = await emit_run_cancelled(
self.store,
run_id=run_id,
reason=request.reason,
message=request.message,
)
if finalization.status != "cancelled":
raise RunCancellationConflictError(f"run already finished with status {finalization.status!r}")
status = await self.store.request_cancellation(run_id, request)
if status in {"succeeded", "failed"}:
raise RunCancellationConflictError(f"run already finished with status {status!r}")
return CancelRunResponse(run_id=run_id, status="cancelled")
async def shutdown(self) -> None:
@ -141,18 +160,14 @@ class RunScheduler:
self.stopping = True
if not self.active_tasks:
return
tasks_by_run_id = dict(self.active_tasks)
done, pending = await asyncio.wait(tasks_by_run_id.values(), timeout=self.shutdown_grace_seconds)
del done
tasks = tuple(self.active_tasks.values())
_done, pending = await asyncio.wait(tasks, timeout=self.shutdown_grace_seconds)
if not pending:
return
pending_run_ids = [run_id for run_id, task in tasks_by_run_id.items() if task in pending]
for task in pending:
_ = task.cancel()
_ = await asyncio.gather(*pending, return_exceptions=True)
for run_id in pending_run_ids:
await self._mark_cancelled_run_failed(run_id)
async def _run_record(self, record: RunRecord, request: CreateRunRequest) -> None:
"""Supervise one local runner and its durable cancellation observer."""
@ -163,41 +178,92 @@ class RunScheduler:
self.store.wait_for_cancellation(record.run_id),
name=f"dify-agent-cancellation-observer-{record.run_id}",
)
async def cancel_runner_and_wait() -> None:
if not cancel_requested.is_set() and not runner_task.done():
cancel_requested.set()
_ = runner_task.cancel()
_ = await asyncio.shield(asyncio.gather(runner_task, return_exceptions=True))
try:
_ = await asyncio.wait((runner_task, observer_task), return_when=asyncio.FIRST_COMPLETED)
if observer_task.done():
try:
cancellation_won = observer_task.result()
intent = observer_task.result()
except Exception as exc:
cancel_requested.set()
await self._cancel_and_wait(runner_task, reinject=True)
_ = await emit_run_failed(
await cancel_runner_and_wait()
finalization = await emit_run_failed(
self.store,
run_id=record.run_id,
error=f"run cancellation observer failed: {exc}",
reason="cancellation_observer",
session_snapshot=runner.terminal_session_snapshot,
)
if not finalization.applied and finalization.status == "running":
intent = await self.store.get_cancellation_intent(record.run_id)
if intent is not None:
_ = await self.store.finalize_cancellation(
record.run_id,
intent,
session_snapshot=runner.terminal_session_snapshot,
)
raise
if cancellation_won:
cancel_requested.set()
await self._cancel_and_wait(runner_task, reinject=True)
if intent is not None:
await cancel_runner_and_wait()
_ = await self.store.finalize_cancellation(
record.run_id,
intent,
session_snapshot=runner.terminal_session_snapshot,
)
else:
await runner_task
else:
await runner_task
runner_error: Exception | None = None
try:
await runner_task
except Exception as exc:
runner_error = exc
intent = await self.store.get_cancellation_intent(record.run_id)
if intent is not None:
_ = await self.store.finalize_cancellation(
record.run_id,
intent,
session_snapshot=runner.terminal_session_snapshot,
)
if runner_error is not None:
raise runner_error
except asyncio.CancelledError:
cancel_requested.set()
await self._cancel_and_wait(observer_task)
await self._cancel_and_wait(runner_task, reinject=True)
await cancel_runner_and_wait()
intent = await self.store.get_cancellation_intent(record.run_id)
if intent is not None:
_ = await self.store.finalize_cancellation(
record.run_id,
intent,
session_snapshot=runner.terminal_session_snapshot,
)
else:
finalization = await self._mark_cancelled_run_failed(
record.run_id,
session_snapshot=runner.terminal_session_snapshot,
)
if finalization is not None and not finalization.applied and finalization.status == "running":
intent = await self.store.get_cancellation_intent(record.run_id)
if intent is not None:
_ = await self.store.finalize_cancellation(
record.run_id,
intent,
session_snapshot=runner.terminal_session_snapshot,
)
raise
except Exception:
logger.exception("scheduled run failed", extra={"run_id": record.run_id})
finally:
await self._cancel_and_wait(observer_task)
if not runner_task.done():
cancel_requested.set()
await self._cancel_and_wait(runner_task, reinject=True)
await cancel_runner_and_wait()
def _create_runner(
self,
@ -234,25 +300,31 @@ class RunScheduler:
_ = self.active_tasks.pop(run_id, None)
@staticmethod
async def _cancel_and_wait(task: asyncio.Task[object], *, reinject: bool = False) -> None:
"""Cancel and reap a child task, with bounded reinjection for runners."""
async def _cancel_and_wait(task: asyncio.Task[object]) -> None:
"""Cancel a child task once and await its complete exit."""
if not task.done():
_ = task.cancel()
if reinject:
for _attempt in range(2):
await asyncio.sleep(0)
if task.done():
break
_ = task.cancel()
_ = await asyncio.gather(task, return_exceptions=True)
async def _mark_cancelled_run_failed(self, run_id: str) -> None:
async def _mark_cancelled_run_failed(
self,
run_id: str,
*,
session_snapshot: CompositorSessionSnapshot | None = None,
) -> RunFinalizationResult | None:
"""Best-effort failure event/status for shutdown-cancelled runs."""
message = "run cancelled during server shutdown"
try:
_ = await emit_run_failed(self.store, run_id=run_id, error=message, reason="shutdown")
return await emit_run_failed(
self.store,
run_id=run_id,
error=message,
reason="shutdown",
session_snapshot=session_snapshot,
)
except Exception:
logger.exception("failed to mark cancelled run failed", extra={"run_id": run_id})
return None
__all__ = ["RunCancellationConflictError", "RunScheduler", "SchedulerStoppingError"]

View File

@ -184,6 +184,7 @@ class AgentRunRunner:
dify_api_http_client: httpx.AsyncClient
is_cancelled: Callable[[], bool]
run_timeout_seconds: float
_terminal_session_snapshot: CompositorSessionSnapshot | None
def __init__(
self,
@ -205,9 +206,16 @@ class AgentRunRunner:
self.layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers()
self.is_cancelled = is_cancelled or (lambda: False)
self.run_timeout_seconds = run_timeout_seconds
self._terminal_session_snapshot = None
@property
def terminal_session_snapshot(self) -> CompositorSessionSnapshot | None:
"""Return the snapshot captured after the current compositor context exited."""
return self._terminal_session_snapshot
async def run(self) -> None:
"""Execute the run and emit the documented event sequence."""
self._terminal_session_snapshot = None
if self.is_cancelled():
return
_ = await emit_run_started(self.sink, run_id=self.run_id)
@ -224,6 +232,7 @@ class AgentRunRunner:
error=message,
error_type=error_type,
reason=reason,
session_snapshot=self._terminal_session_snapshot,
)
if finalization.applied:
raise
@ -287,6 +296,7 @@ class AgentRunRunner:
deferred_tool_call: DeferredToolCallPayload | None = None
result_kind: Literal["output", "deferred_tool_call"] | None = None
usage: AgentRunUsage | None = None
run = None
try:
async with compositor.enter(configs=layer_configs, session_snapshot=self.request.session_snapshot) as run:
entered_run = True
@ -383,8 +393,11 @@ class AgentRunRunner:
if not entered_run:
raise AgentRunValidationError(str(exc)) from exc
raise
finally:
if entered_run and run is not None:
self._terminal_session_snapshot = run.session_snapshot
if run.session_snapshot is None:
if run is None or run.session_snapshot is None:
raise RuntimeError("Agenton run did not produce a session snapshot after exit.")
if result_kind is None:
raise RuntimeError("Agent run did not resolve either a final output or a deferred tool call.")

View File

@ -11,4 +11,9 @@ def run_events_key(prefix: str, run_id: str) -> str:
return f"{prefix}:runs:{run_id}:events"
__all__ = ["run_events_key", "run_record_key"]
def run_cancel_intent_key(prefix: str, run_id: str) -> str:
"""Return the private Redis stream key holding one cancellation intent."""
return f"{prefix}:runs:{run_id}:cancel-intent"
__all__ = ["run_cancel_intent_key", "run_events_key", "run_record_key"]

View File

@ -1,8 +1,8 @@
"""Redis-backed run records and per-run event streams.
"""Redis-backed run records, event streams, and private cancellation intents.
The store writes status-only run records as JSON strings and events as Redis
streams. HTTP event cursors are Redis stream ids; ``0-0`` means replay from the
beginning for polling and SSE. Records and streams share one retention window
beginning for polling and SSE. Records, event streams, and intents share one retention window
that is refreshed when status or event data is written. Execution is scheduled
in-process by ``dify_agent.runtime.run_scheduler``; Redis is not a job queue, and
create-run payloads are never persisted because layer config may include
@ -14,7 +14,18 @@ from typing import cast
from redis.asyncio import Redis
from dify_agent.protocol.schemas import RUN_EVENT_ADAPTER, RunEvent, RunEventsResponse, RunStatus
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol.schemas import (
RUN_EVENT_ADAPTER,
CancelRunRequest,
RunCancelledEvent,
RunCancelledEventData,
RunEvent,
RunEventsResponse,
RunStatus,
utc_now,
)
from dify_agent.runtime.cancellation import RunCancellationIntent
from dify_agent.runtime.event_sink import (
NonTerminalRunEvent,
RunEventSink,
@ -24,7 +35,7 @@ from dify_agent.runtime.event_sink import (
)
from dify_agent.server.schemas import RunRecord, new_run_id
from dify_agent.server.settings import DEFAULT_RUN_RETENTION_SECONDS
from dify_agent.storage.redis_keys import run_events_key, run_record_key
from dify_agent.storage.redis_keys import run_cancel_intent_key, run_events_key, run_record_key
_TERMINAL_RUN_EVENT_TYPES = {"run_succeeded", "run_failed", "run_cancelled"}
@ -44,6 +55,10 @@ if record.status ~= "running" then
return {0, tostring(record.status), ""}
end
if redis.call("EXISTS", KEYS[3]) == 1 then
return {-2, "running", ""}
end
record.status = ARGV[1]
record.updated_at = ARGV[2]
if ARGV[3] == "1" then
@ -66,6 +81,67 @@ return {1, ARGV[1], event_id}
"""
_REQUEST_CANCELLATION_SCRIPT = """
local record_json = redis.call("GET", KEYS[1])
if not record_json then
return {-1, ""}
end
local record = cjson.decode(record_json)
if record.status == "succeeded" or record.status == "failed" then
return {0, tostring(record.status)}
end
if record.status == "cancelled" then
return {1, "cancelled"}
end
if redis.call("EXISTS", KEYS[2]) == 1 then
return {1, "running"}
end
local ttl = tonumber(ARGV[2])
redis.call("XADD", KEYS[2], "*", "payload", ARGV[1])
redis.call("EXPIRE", KEYS[2], ttl)
redis.call("EXPIRE", KEYS[1], ttl)
redis.call("EXPIRE", KEYS[3], ttl)
return {1, "running"}
"""
_FINALIZE_CANCELLATION_SCRIPT = """
local record_json = redis.call("GET", KEYS[1])
if not record_json then
return {-1, "", ""}
end
local record = cjson.decode(record_json)
if record.status == "cancelled" then
return {0, "cancelled", ""}
end
if record.status ~= "running" then
return {0, tostring(record.status), ""}
end
if redis.call("EXISTS", KEYS[2]) == 0 then
return {-2, "running", ""}
end
record.status = "cancelled"
record.updated_at = ARGV[1]
if ARGV[2] == "1" then
record.error = ARGV[3]
else
record.error = cjson.null
end
record.error_type = cjson.null
local ttl = tonumber(ARGV[5])
local event_id = redis.call("XADD", KEYS[3], "*", "payload", ARGV[4])
redis.call("DEL", KEYS[2])
redis.call("EXPIRE", KEYS[3], ttl)
redis.call("SET", KEYS[1], cjson.encode(record), "EX", ttl)
return {1, "cancelled", event_id}
"""
class RedisRunStore(RunEventSink):
"""Async Redis implementation for run records and event logs.
@ -130,16 +206,17 @@ class RedisRunStore(RunEventSink):
return event_id.decode() if isinstance(event_id, bytes) else str(event_id)
async def finalize_run(self, event: TerminalRunEvent) -> RunFinalizationResult:
"""Atomically append the first terminal event and update its run record."""
"""Atomically append the first success/failure event and update its run record."""
status, error, error_type = terminal_event_status_fields(event)
payload = RUN_EVENT_ADAPTER.dump_json(event, exclude={"id"}).decode()
evaluation = cast(
Awaitable[object],
self.redis.eval(
_FINALIZE_RUN_SCRIPT,
2,
3,
run_record_key(self.prefix, event.run_id),
run_events_key(self.prefix, event.run_id),
run_cancel_intent_key(self.prefix, event.run_id),
status,
event.created_at.isoformat(),
"1" if error is not None else "0",
@ -164,8 +241,39 @@ class RedisRunStore(RunEventSink):
event_id=event_id,
)
async def wait_for_cancellation(self, run_id: str) -> bool:
"""Wait until cancellation or another terminal state wins for one run.
async def request_cancellation(self, run_id: str, request: CancelRunRequest) -> RunStatus:
"""Atomically persist the first cancellation intent for a running run."""
intent = RunCancellationIntent(
reason=request.reason,
message=request.message,
requested_at=utc_now(),
)
evaluation = cast(
Awaitable[object],
self.redis.eval(
_REQUEST_CANCELLATION_SCRIPT,
3,
run_record_key(self.prefix, run_id),
run_cancel_intent_key(self.prefix, run_id),
run_events_key(self.prefix, run_id),
intent.model_dump_json(),
str(self.run_retention_seconds),
),
)
result = cast(list[object], await evaluation)
if int(cast(int | bytes | str, result[0])) == -1:
raise RunNotFoundError(run_id)
return cast(RunStatus, _decode_redis_text(result[1]))
async def get_cancellation_intent(self, run_id: str) -> RunCancellationIntent | None:
"""Return the accepted private cancellation intent, if one exists."""
entries = await self.redis.xrange(run_cancel_intent_key(self.prefix, run_id), count=1)
if not entries:
return None
return self._decode_cancellation_intent(entries[0][1])
async def wait_for_cancellation(self, run_id: str) -> RunCancellationIntent | None:
"""Wait until cancellation intent or another terminal state wins.
The stream cursor is captured before reading the record so a terminal
transition cannot fall between the initial status check and blocking
@ -176,19 +284,76 @@ class RedisRunStore(RunEventSink):
cursor = _decode_redis_text(latest_events[0][0]) if latest_events else "0-0"
record = await self.get_run(run_id)
if record.status != "running":
return record.status == "cancelled"
return None
intent = await self.get_cancellation_intent(run_id)
if intent is not None:
return intent
while True:
response = await self.redis.xread({events_key: cursor}, block=0, count=100)
for _stream_name, entries in response:
response = await self.redis.xread(
{
run_cancel_intent_key(self.prefix, run_id): "0-0",
events_key: cursor,
},
block=0,
count=100,
)
for stream_name, entries in response:
if _decode_redis_text(stream_name) == run_cancel_intent_key(self.prefix, run_id):
return self._decode_cancellation_intent(entries[0][1])
for raw_id, fields in entries:
event = self._decode_event(run_id, raw_id, fields)
if event.id is not None:
cursor = event.id
if event.type == "run_cancelled":
return True
return None
if event.type in {"run_succeeded", "run_failed"}:
return False
return None
async def finalize_cancellation(
self,
run_id: str,
intent: RunCancellationIntent,
*,
session_snapshot: CompositorSessionSnapshot | None = None,
) -> RunFinalizationResult:
"""Atomically publish cancellation after the owner runner has exited."""
event = RunCancelledEvent(
run_id=run_id,
data=RunCancelledEventData(
reason=intent.reason,
message=intent.message,
session_snapshot=session_snapshot,
),
created_at=utc_now(),
)
payload = RUN_EVENT_ADAPTER.dump_json(event, exclude={"id"}).decode()
error = event.data.message or event.data.reason
evaluation = cast(
Awaitable[object],
self.redis.eval(
_FINALIZE_CANCELLATION_SCRIPT,
3,
run_record_key(self.prefix, run_id),
run_cancel_intent_key(self.prefix, run_id),
run_events_key(self.prefix, run_id),
event.created_at.isoformat(),
"1" if error is not None else "0",
error or "",
payload,
str(self.run_retention_seconds),
),
)
result = cast(list[object], await evaluation)
applied = int(cast(int | bytes | str, result[0]))
if applied == -1:
raise RunNotFoundError(run_id)
return RunFinalizationResult(
applied=applied == 1,
status=cast(RunStatus, _decode_redis_text(result[1])),
event_id=_decode_redis_text(result[2]) or None,
)
async def get_events(self, run_id: str, *, after: str = "0-0", limit: int = 100) -> RunEventsResponse:
"""Read a bounded page of events after ``after`` cursor."""
@ -235,6 +400,13 @@ class RedisRunStore(RunEventSink):
event = RUN_EVENT_ADAPTER.validate_json(cast(str, payload))
return event.model_copy(update={"id": event_id, "run_id": run_id})
@staticmethod
def _decode_cancellation_intent(fields: dict[object, object]) -> RunCancellationIntent:
payload = fields.get(b"payload") or fields.get("payload")
if isinstance(payload, bytes):
payload = payload.decode()
return RunCancellationIntent.model_validate_json(cast(str, payload))
def _decode_redis_text(value: object) -> str:
return value.decode() if isinstance(value, bytes) else str(value)

View File

@ -16,24 +16,36 @@ from agenton.compositor import CompositorSessionSnapshot
from dify_agent.protocol.schemas import (
CancelRunRequest,
CreateRunRequest,
RunCancelledEvent,
RunCancelledEventData,
RunComposition,
RunFailedEvent,
RunFailedEventData,
RunFailureType,
RunStartedEvent,
RunSucceededEvent,
RunSucceededEventData,
utc_now,
)
from dify_agent.runtime.event_sink import TerminalRunEvent, terminal_event_status_fields
from dify_agent.runtime.cancellation import RunCancellationIntent
from dify_agent.runtime.run_scheduler import RunScheduler
from dify_agent.storage.redis_keys import run_events_key, run_record_key
from dify_agent.storage.redis_keys import run_cancel_intent_key, run_events_key, run_record_key
from dify_agent.storage.redis_run_store import RedisRunStore
pytestmark = pytest.mark.integration
def _success_or_failure_event(kind: str, run_id: str) -> RunSucceededEvent | RunFailedEvent:
if kind == "succeeded":
return RunSucceededEvent(
run_id=run_id,
data=RunSucceededEventData(
output="done",
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
)
return RunFailedEvent(run_id=run_id, data=RunFailedEventData(error="model failed"))
@pytest.fixture
def redis_url() -> Iterator[str]:
"""Start an isolated Redis when the binary is available locally."""
@ -82,7 +94,7 @@ def redis_url() -> Iterator[str]:
process.wait(timeout=5)
def test_two_redis_clients_commit_exactly_one_matching_terminal(redis_url: str) -> None:
def test_success_and_cancel_intent_commit_exactly_one_matching_terminal(redis_url: str) -> None:
async def scenario() -> None:
first_client = Redis.from_url(redis_url)
second_client = Redis.from_url(redis_url)
@ -92,43 +104,42 @@ def test_two_redis_clients_commit_exactly_one_matching_terminal(redis_url: str)
second_store = RedisRunStore(second_client, prefix=prefix, run_retention_seconds=retention_seconds)
try:
record = await first_store.create_run()
terminal_events: tuple[TerminalRunEvent, TerminalRunEvent] = (
RunSucceededEvent(
run_id=record.run_id,
data=RunSucceededEventData(
output="done",
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
),
RunCancelledEvent(
run_id=record.run_id,
data=RunCancelledEventData(
reason="concurrent_cancel",
message="cancel accepted",
),
success_event = RunSucceededEvent(
run_id=record.run_id,
data=RunSucceededEventData(
output="done",
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
)
results = await asyncio.gather(
first_store.finalize_run(terminal_events[0]),
second_store.finalize_run(terminal_events[1]),
success_result, cancellation_status = await asyncio.gather(
first_store.finalize_run(success_event),
second_store.request_cancellation(
record.run_id,
CancelRunRequest(reason="concurrent_cancel", message="cancel accepted"),
),
)
assert sum(result.applied for result in results) == 1
winner_index = next(index for index, result in enumerate(results) if result.applied)
winner_event = terminal_events[winner_index]
winner_result = results[winner_index]
expected_status, expected_error, expected_error_type = terminal_event_status_fields(winner_event)
if success_result.applied:
assert cancellation_status == "succeeded"
winner_result = success_result
expected_status = "succeeded"
expected_event_type = "run_succeeded"
else:
assert success_result.status == "running"
assert cancellation_status == "running"
intent = await first_store.get_cancellation_intent(record.run_id)
assert intent is not None
winner_result = await first_store.finalize_cancellation(record.run_id, intent)
assert winner_result.applied is True
expected_status = "cancelled"
expected_event_type = "run_cancelled"
persisted = await first_store.get_run(record.run_id)
page = await second_store.get_events(record.run_id)
assert persisted.status == expected_status
assert persisted.error == expected_error
assert persisted.error_type == expected_error_type
assert persisted.updated_at == winner_event.created_at
assert len(page.events) == 1
assert page.events[0].type == winner_event.type
assert page.events[0].created_at == winner_event.created_at
assert page.events[0].type == expected_event_type
assert page.events[0].id == winner_result.event_id
record_ttl = await first_client.ttl(run_record_key(prefix, record.run_id))
@ -142,6 +153,116 @@ def test_two_redis_clients_commit_exactly_one_matching_terminal(redis_url: str)
asyncio.run(scenario())
@pytest.mark.parametrize("terminal_status", ["succeeded", "failed"])
def test_terminal_first_rejects_late_cancellation(redis_url: str, terminal_status: str) -> None:
async def scenario() -> None:
client = Redis.from_url(redis_url)
prefix = f"terminal-first-{terminal_status}-{uuid4().hex}"
store = RedisRunStore(client, prefix=prefix, run_retention_seconds=60)
try:
record = await store.create_run()
result = await store.finalize_run(_success_or_failure_event(terminal_status, record.run_id))
cancellation_status = await store.request_cancellation(
record.run_id,
CancelRunRequest(reason="late_cancel"),
)
assert result.applied is True
assert cancellation_status == terminal_status
assert await store.get_cancellation_intent(record.run_id) is None
events = await store.get_events(record.run_id)
assert [event.type for event in events.events] == [f"run_{terminal_status}"]
finally:
await client.aclose()
asyncio.run(scenario())
def test_cancellation_intent_lifecycle_and_terminal_exclusion(redis_url: str) -> None:
async def scenario() -> None:
client = Redis.from_url(redis_url)
prefix = f"cancel-intent-lifecycle-{uuid4().hex}"
retention_seconds = 60
store = RedisRunStore(client, prefix=prefix, run_retention_seconds=retention_seconds)
try:
record = await store.create_run()
_ = await store.append_event(RunStartedEvent(run_id=record.run_id))
record_key = run_record_key(prefix, record.run_id)
events_key = run_events_key(prefix, record.run_id)
intent_key = run_cancel_intent_key(prefix, record.run_id)
_ = await client.expire(record_key, 1)
_ = await client.expire(events_key, 1)
first_status = await store.request_cancellation(
record.run_id,
CancelRunRequest(reason="first", message="first message"),
)
duplicate_status = await store.request_cancellation(
record.run_id,
CancelRunRequest(reason="second", message="second message"),
)
intent = await store.get_cancellation_intent(record.run_id)
assert first_status == duplicate_status == "running"
assert intent is not None
assert (intent.reason, intent.message) == ("first", "first message")
for key in (record_key, events_key, intent_key):
assert 0 < await client.ttl(key) <= retention_seconds
success = await store.finalize_run(_success_or_failure_event("succeeded", record.run_id))
failure = await store.finalize_run(_success_or_failure_event("failed", record.run_id))
assert (success.applied, success.status) == (False, "running")
assert (failure.applied, failure.status) == (False, "running")
assert [event.type for event in (await store.get_events(record.run_id)).events] == ["run_started"]
first_finalization = await store.finalize_cancellation(
record.run_id,
intent,
session_snapshot=CompositorSessionSnapshot(layers=[]),
)
repeated_finalization = await store.finalize_cancellation(record.run_id, intent)
post_terminal_status = await store.request_cancellation(
record.run_id,
CancelRunRequest(reason="after_finalization"),
)
events = await store.get_events(record.run_id)
assert first_finalization.applied is True
assert repeated_finalization.applied is False
assert repeated_finalization.status == "cancelled"
assert post_terminal_status == "cancelled"
assert [event.type for event in events.events].count("run_cancelled") == 1
assert await client.exists(intent_key) == 0
for key in (record_key, events_key):
assert 0 < await client.ttl(key) <= retention_seconds
finally:
await client.aclose()
asyncio.run(scenario())
def test_cancellation_finalization_without_intent_is_unapplied(redis_url: str) -> None:
async def scenario() -> None:
client = Redis.from_url(redis_url)
store = RedisRunStore(client, prefix=f"cancel-without-intent-{uuid4().hex}", run_retention_seconds=60)
try:
record = await store.create_run()
result = await store.finalize_cancellation(
record.run_id,
RunCancellationIntent(reason="not-accepted", requested_at=utc_now()),
)
assert result.applied is False
assert result.status == "running"
assert (await store.get_events(record.run_id)).events == []
assert (await store.get_run(record.run_id)).status == "running"
finally:
await client.aclose()
asyncio.run(scenario())
def test_classified_failure_persists_matching_record_and_event_error_type(redis_url: str) -> None:
async def scenario() -> None:
client = Redis.from_url(redis_url)
@ -178,6 +299,10 @@ def test_non_owner_scheduler_cancellation_stops_owner_runner(redis_url: str) ->
self.started = started
self.stopped = stopped
@property
def terminal_session_snapshot(self) -> None:
return None
async def run(self) -> None:
self.started.set()
try:

View File

@ -34,6 +34,7 @@ from dify_agent.protocol import (
DestroyExecutionBindingRequest,
RUN_EVENT_ADAPTER,
RunCancelledEvent,
RunCancelledEventData,
RunEvent,
RunEventsResponse,
RunFailedEvent,
@ -245,6 +246,109 @@ def test_async_methods_and_wait_run_parse_protocol_dtos() -> None:
asyncio.run(scenario())
def test_cancel_run_and_wait_sync_resumes_after_cursor_and_returns_cancelled_snapshot() -> None:
snapshot = CompositorSessionSnapshot(layers=[])
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(202, json={"run_id": "run-1", "status": "cancelled"})
if request.url.path == "/runs/run-1":
return httpx.Response(
200,
json={
"run_id": "run-1",
"status": "running",
"created_at": "2026-08-17T00:00:00Z",
"updated_at": "2026-08-17T00:00:00Z",
},
)
assert request.url.params["after"] == "3-0"
event = RunCancelledEvent(
id="4-0",
run_id="run-1",
data=RunCancelledEventData(reason="stopped", session_snapshot=snapshot),
)
return httpx.Response(200, content=_event_frame(event))
client = Client(
base_url="http://testserver",
sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
event = client.cancel_run_and_wait_sync(
"run-1",
CancelRunRequest(reason="stopped"),
after="3-0",
)
assert event.data.session_snapshot == snapshot
def test_cancel_run_and_wait_sync_replays_when_cursor_already_points_to_cancelled_event() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(202, json={"run_id": "run-1", "status": "cancelled"})
if request.url.path == "/runs/run-1":
return httpx.Response(
200,
json={
"run_id": "run-1",
"status": "cancelled",
"created_at": "2026-08-17T00:00:00Z",
"updated_at": "2026-08-17T00:00:01Z",
},
)
assert request.url.params["after"] == "0-0"
return httpx.Response(200, content=_event_frame(RunCancelledEvent(id="4-0", run_id="run-1")))
client = Client(
base_url="http://testserver",
sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
event = client.cancel_run_and_wait_sync("run-1", after="4-0")
assert event.id == "4-0"
def test_cancel_run_and_wait_async_returns_cancelled_terminal() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(202, json={"run_id": "run-1", "status": "cancelled"})
if request.url.path == "/runs/run-1":
return httpx.Response(200, json=_run_status_json("running"))
assert request.url.params["after"] == "1-0"
return httpx.Response(200, content=_event_frame(RunCancelledEvent(id="2-0", run_id="run-1")))
async def scenario() -> None:
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
client = Client(base_url="http://testserver", async_http_client=http_client)
event = await client.cancel_run_and_wait("run-1", after="1-0")
assert event.type == "run_cancelled"
await http_client.aclose()
asyncio.run(scenario())
def test_cancel_run_and_wait_async_replays_when_cursor_already_points_to_cancelled_event() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(202, json={"run_id": "run-1", "status": "cancelled"})
if request.url.path == "/runs/run-1":
return httpx.Response(200, json=_run_status_json("cancelled"))
assert request.url.params["after"] == "0-0"
return httpx.Response(200, content=_event_frame(RunCancelledEvent(id="4-0", run_id="run-1")))
async def scenario() -> None:
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
client = Client(base_url="http://testserver", async_http_client=http_client)
event = await client.cancel_run_and_wait("run-1", after="4-0")
assert event.id == "4-0"
await http_client.aclose()
asyncio.run(scenario())
def test_sync_binding_file_methods_post_dtos_and_parse_responses() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/execution-bindings/files/list":

View File

@ -77,7 +77,12 @@ def test_run_event_adapter_round_trips_typed_variants() -> None:
reason="shutdown",
),
),
RunCancelledEvent(run_id="run-1", data=RunCancelledEventData(reason="user_cancelled")),
RunCancelledEvent(
run_id="run-1",
data=RunCancelledEventData(
reason="user_cancelled",
),
),
]
for event in events:
@ -113,6 +118,27 @@ def test_run_failed_event_error_type_is_optional_and_round_trips() -> None:
assert protocol_exports.RunFailureType is RunFailureType
@pytest.mark.parametrize("event_type", ["run_failed", "run_cancelled"])
def test_non_success_terminal_event_round_trips_optional_snapshot(event_type: str) -> None:
snapshot = CompositorSessionSnapshot(layers=[])
event: RunFailedEvent | RunCancelledEvent
if event_type == "run_failed":
event = RunFailedEvent(
run_id="run-1",
data=RunFailedEventData(error="boom", session_snapshot=snapshot),
)
else:
event = RunCancelledEvent(
run_id="run-1",
data=RunCancelledEventData(reason="stopped", session_snapshot=snapshot),
)
decoded = RUN_EVENT_ADAPTER.validate_json(RUN_EVENT_ADAPTER.dump_json(event))
assert isinstance(decoded, RunFailedEvent | RunCancelledEvent)
assert decoded.data.session_snapshot == snapshot
def test_pydantic_ai_event_data_uses_agent_stream_event_model() -> None:
event = RUN_EVENT_ADAPTER.validate_python(
{

View File

@ -15,11 +15,16 @@ from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAY
from dify_agent.protocol.schemas import (
CancelRunRequest,
CreateRunRequest,
RunCancelledEvent,
RunCancelledEventData,
RunComposition,
RunEvent,
RunFailedEvent,
RunLayerSpec,
RunStatus,
utc_now,
)
from dify_agent.runtime.cancellation import RunCancellationIntent
from dify_agent.runtime.event_sink import (
NonTerminalRunEvent,
RunFinalizationResult,
@ -95,6 +100,7 @@ class FakeStore:
errors: dict[str, str | None]
error_types: dict[str, RunFailureType | None]
terminal_changes: dict[str, asyncio.Event]
cancellation_intents: dict[str, RunCancellationIntent]
def __init__(self) -> None:
self.records = {}
@ -103,6 +109,7 @@ class FakeStore:
self.errors = {}
self.error_types = {}
self.terminal_changes = {}
self.cancellation_intents = {}
async def create_run(self) -> RunRecord:
run_id = f"run-{len(self.records) + 1}"
@ -130,6 +137,8 @@ class FakeStore:
current_status = self.statuses[event.run_id]
if current_status != "running":
return RunFinalizationResult(applied=False, status=current_status)
if event.run_id in self.cancellation_intents:
return RunFinalizationResult(applied=False, status="running")
status, error, error_type = terminal_event_status_fields(event)
event_id = str(len(self.events[event.run_id]) + 1)
@ -140,10 +149,55 @@ class FakeStore:
self.terminal_changes[event.run_id].set()
return RunFinalizationResult(applied=True, status=status, event_id=event_id)
async def wait_for_cancellation(self, run_id: str) -> bool:
while self.statuses[run_id] == "running":
async def request_cancellation(self, run_id: str, request: CancelRunRequest) -> RunStatus:
status = self.statuses[run_id]
if status != "running":
return status
if run_id not in self.cancellation_intents:
self.cancellation_intents[run_id] = RunCancellationIntent(
reason=request.reason,
message=request.message,
requested_at=utc_now(),
)
self.terminal_changes[run_id].set()
return "running"
async def get_cancellation_intent(self, run_id: str) -> RunCancellationIntent | None:
return self.cancellation_intents.get(run_id)
async def wait_for_cancellation(self, run_id: str) -> RunCancellationIntent | None:
while self.statuses[run_id] == "running" and run_id not in self.cancellation_intents:
await self.terminal_changes[run_id].wait()
return self.statuses[run_id] == "cancelled"
return self.cancellation_intents.get(run_id)
async def finalize_cancellation(
self,
run_id: str,
intent: RunCancellationIntent,
*,
session_snapshot: CompositorSessionSnapshot | None = None,
) -> RunFinalizationResult:
current_status = self.statuses[run_id]
if current_status != "running":
return RunFinalizationResult(applied=False, status=current_status)
if run_id not in self.cancellation_intents:
return RunFinalizationResult(applied=False, status="running")
event = RunCancelledEvent(
run_id=run_id,
data=RunCancelledEventData(
reason=intent.reason,
message=intent.message,
session_snapshot=session_snapshot,
),
)
event_id = str(len(self.events[run_id]) + 1)
self.events[run_id].append(event.model_copy(update={"id": event_id}))
self.statuses[run_id] = "cancelled"
self.errors[run_id] = intent.message or intent.reason
self.error_types[run_id] = None
del self.cancellation_intents[run_id]
self.terminal_changes[run_id].set()
return RunFinalizationResult(applied=True, status="cancelled", event_id=event_id)
class SlowCreateStore(FakeStore):
@ -174,7 +228,7 @@ class TrackingStore(FakeStore):
if not pause_observer:
self.release_observer.set()
async def wait_for_cancellation(self, run_id: str) -> bool:
async def wait_for_cancellation(self, run_id: str) -> RunCancellationIntent | None:
self.observer_started.set()
try:
await self.release_observer.wait()
@ -192,7 +246,7 @@ class FailingObserverStore(FakeStore):
self.fail_observer = fail_observer
self.observer_finished = asyncio.Event()
async def wait_for_cancellation(self, run_id: str) -> bool:
async def wait_for_cancellation(self, run_id: str) -> RunCancellationIntent | None:
del run_id
try:
await self.fail_observer.wait()
@ -201,10 +255,27 @@ class FailingObserverStore(FakeStore):
self.observer_finished.set()
class CancellationDuringShutdownFailureStore(FakeStore):
async def finalize_run(self, event: TerminalRunEvent) -> RunFinalizationResult:
if isinstance(event, RunFailedEvent) and event.data.reason == "shutdown":
_ = await self.request_cancellation(
event.run_id,
CancelRunRequest(reason="concurrent_shutdown_cancel"),
)
return await super().finalize_run(event)
class SnapshotlessRunner:
@property
def terminal_session_snapshot(self) -> CompositorSessionSnapshot | None:
return None
class ControlledRunner:
started: asyncio.Event
release: asyncio.Event
finished: asyncio.Event | None
_terminal_session_snapshot: CompositorSessionSnapshot
def __init__(
self,
@ -216,6 +287,11 @@ class ControlledRunner:
self.started = started
self.release = release
self.finished = finished
self._terminal_session_snapshot = CompositorSessionSnapshot(layers=[])
@property
def terminal_session_snapshot(self) -> CompositorSessionSnapshot:
return self._terminal_session_snapshot
async def run(self) -> None:
_ = self.started.set()
@ -226,24 +302,16 @@ class ControlledRunner:
self.finished.set()
class SwallowOneCancellationRunner:
started: asyncio.Event
first_cancellation: asyncio.Event
def __init__(self, *, started: asyncio.Event, first_cancellation: asyncio.Event) -> None:
class PreEnterBlockingRunner(SnapshotlessRunner):
def __init__(self, *, started: asyncio.Event) -> None:
self.started = started
self.first_cancellation = first_cancellation
async def run(self) -> None:
_ = self.started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
_ = self.first_cancellation.set()
await asyncio.Event().wait()
self.started.set()
await asyncio.Event().wait()
class SuccessThenWaitRunner:
class SuccessThenWaitRunner(SnapshotlessRunner):
def __init__(
self,
*,
@ -269,7 +337,7 @@ class SuccessThenWaitRunner:
await self.release.wait()
class IgnoreCancellationThenSucceedRunner:
class IgnoreCancellationThenSucceedRunner(SnapshotlessRunner):
def __init__(
self,
*,
@ -300,12 +368,12 @@ class IgnoreCancellationThenSucceedRunner:
session_snapshot=CompositorSessionSnapshot(layers=[]),
)
assert result.applied is False
assert result.status == "cancelled"
assert result.status == "running"
finally:
self.finished.set()
class ReleaseThenSucceedRunner:
class ReleaseThenSucceedRunner(SnapshotlessRunner):
def __init__(
self,
*,
@ -336,7 +404,7 @@ class ReleaseThenSucceedRunner:
self.finished.set()
class CompetingFailureRunner:
class CompetingFailureRunner(SnapshotlessRunner):
def __init__(
self,
*,
@ -362,7 +430,7 @@ class CompetingFailureRunner:
self.failure_attempted.set()
class FinalizeSuccessOnCancellationRunner:
class FinalizeSuccessOnCancellationRunner(SnapshotlessRunner):
def __init__(self, *, store: FakeStore, run_id: str, started: asyncio.Event) -> None:
self.store = store
self.run_id = run_id
@ -454,6 +522,37 @@ def test_shutdown_marks_unfinished_runs_failed_and_appends_event() -> None:
asyncio.run(scenario())
def test_shutdown_failure_finalization_yields_to_concurrent_cancellation_intent() -> None:
async def scenario() -> None:
store = CancellationDuringShutdownFailureStore()
started = asyncio.Event()
async with httpx.AsyncClient() as client:
scheduler = RunScheduler(
store=store,
plugin_daemon_http_client=client,
dify_api_http_client=client,
shutdown_grace_seconds=0,
runner_factory=lambda _record, _request: ControlledRunner(
started=started,
release=asyncio.Event(),
),
)
record = await scheduler.create_run(_request())
await asyncio.wait_for(started.wait(), timeout=1)
await scheduler.shutdown()
assert store.statuses[record.run_id] == "cancelled"
assert record.run_id not in store.cancellation_intents
assert [event.type for event in store.events[record.run_id]] == ["run_cancelled"]
terminal = store.events[record.run_id][0]
assert isinstance(terminal, RunCancelledEvent)
assert terminal.data.reason == "concurrent_shutdown_cancel"
assert terminal.data.session_snapshot == CompositorSessionSnapshot(layers=[])
asyncio.run(scenario())
def test_cancellation_observer_failure_stops_runner_and_finalizes_failed() -> None:
async def scenario() -> None:
fail_observer = asyncio.Event()
@ -489,6 +588,46 @@ def test_cancellation_observer_failure_stops_runner_and_finalizes_failed() -> No
asyncio.run(scenario())
def test_cancellation_observer_failure_finalizes_concurrent_intent_after_runner_exit() -> None:
async def scenario() -> None:
fail_observer = asyncio.Event()
store = FailingObserverStore(fail_observer=fail_observer)
runner_started = asyncio.Event()
runner_finished = asyncio.Event()
async with httpx.AsyncClient() as client:
scheduler = RunScheduler(
store=store,
plugin_daemon_http_client=client,
dify_api_http_client=client,
runner_factory=lambda _record, _request: ControlledRunner(
started=runner_started,
release=asyncio.Event(),
finished=runner_finished,
),
)
record = await scheduler.create_run(_request())
supervisor_task = scheduler.active_tasks[record.run_id]
await asyncio.wait_for(runner_started.wait(), timeout=1)
response = await scheduler.cancel_run(
record.run_id,
CancelRunRequest(reason="workflow_aborted", message="outer workflow stopped"),
)
fail_observer.set()
await asyncio.wait_for(supervisor_task, timeout=1)
assert response.status == "cancelled"
assert runner_finished.is_set()
assert store.statuses[record.run_id] == "cancelled"
assert record.run_id not in store.cancellation_intents
assert [event.type for event in store.events[record.run_id]] == ["run_cancelled"]
terminal = store.events[record.run_id][0]
assert isinstance(terminal, RunCancelledEvent)
assert terminal.data.session_snapshot == CompositorSessionSnapshot(layers=[])
asyncio.run(scenario())
def test_non_owner_cancel_run_stops_owner_task_and_persists_cancelled_terminal() -> None:
async def scenario() -> None:
store = TrackingStore()
@ -522,10 +661,13 @@ def test_non_owner_cancel_run_stops_owner_task_and_persists_cancelled_terminal()
assert response.status == "cancelled"
assert remote_scheduler.active_tasks == {}
await asyncio.wait_for(owner_task, timeout=1)
assert store.statuses[record.run_id] == "cancelled"
assert store.errors[record.run_id] == "outer workflow stopped"
assert [event.type for event in store.events[record.run_id]] == ["run_cancelled"]
await asyncio.wait_for(owner_task, timeout=1)
terminal = store.events[record.run_id][0]
assert isinstance(terminal, RunCancelledEvent)
assert terminal.data.session_snapshot == CompositorSessionSnapshot(layers=[])
assert runner_finished.is_set()
assert store.observer_finished.is_set()
await asyncio.sleep(0)
@ -538,37 +680,38 @@ def test_non_owner_cancel_run_stops_owner_task_and_persists_cancelled_terminal()
asyncio.run(scenario())
def test_owner_observer_reinjects_cancellation_consumed_by_runner() -> None:
def test_pre_enter_cancellation_does_not_copy_input_session_snapshot() -> None:
async def scenario() -> None:
store = FakeStore()
started = asyncio.Event()
first_cancellation = asyncio.Event()
request = _request()
request.session_snapshot = CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(
name="prior",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={"value": "prior"},
)
]
)
async with httpx.AsyncClient() as client:
scheduler = RunScheduler(
store=store,
plugin_daemon_http_client=client,
dify_api_http_client=client,
runner_factory=lambda _record, _request: SwallowOneCancellationRunner(
started=started,
first_cancellation=first_cancellation,
),
runner_factory=lambda _record, _request: PreEnterBlockingRunner(started=started),
)
record = await scheduler.create_run(_request())
supervisor_task = scheduler.active_tasks[record.run_id]
record = await scheduler.create_run(request)
supervisor = scheduler.active_tasks[record.run_id]
await asyncio.wait_for(started.wait(), timeout=1)
response = await asyncio.wait_for(
scheduler.cancel_run(record.run_id, CancelRunRequest(reason="workflow_aborted")),
timeout=1,
)
_ = await scheduler.cancel_run(record.run_id, CancelRunRequest(reason="pre_enter_cancel"))
await asyncio.wait_for(supervisor, timeout=1)
assert response.status == "cancelled"
assert store.statuses[record.run_id] == "cancelled"
assert [event.type for event in store.events[record.run_id]] == ["run_cancelled"]
await asyncio.wait_for(first_cancellation.wait(), timeout=1)
await asyncio.wait_for(supervisor_task, timeout=1)
await asyncio.sleep(0)
assert scheduler.active_tasks == {}
terminal = store.events[record.run_id][0]
assert isinstance(terminal, RunCancelledEvent)
assert request.session_snapshot is not None
assert terminal.data.session_snapshot is None
asyncio.run(scenario())
@ -634,8 +777,9 @@ def test_cancelled_terminal_survives_shutdown_while_runner_cleanup_is_pending()
response = await scheduler.cancel_run(record.run_id, CancelRunRequest(reason="workflow_aborted"))
assert response.status == "cancelled"
assert store.statuses[record.run_id] == "cancelled"
assert [event.type for event in store.events[record.run_id]] == ["run_cancelled"]
assert store.statuses[record.run_id] == "running"
assert store.events[record.run_id] == []
assert record.run_id in store.cancellation_intents
await asyncio.wait_for(store.observer_finished.wait(), timeout=1)
assert supervisor_task.done() is False
shutdown_task = asyncio.create_task(scheduler.shutdown())

View File

@ -67,7 +67,7 @@ from dify_agent.protocol.schemas import (
RunLayerSpec,
RunSucceededEvent,
)
from dify_agent.runtime.event_sink import InMemoryRunEventSink, emit_run_cancelled
from dify_agent.runtime.event_sink import InMemoryRunEventSink
from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.runner import (
AgentRunRunner,
@ -224,7 +224,7 @@ def test_run_failed_error_payload_classifies_usage_limit() -> None:
assert reason is None
def test_cancelled_runner_does_not_overwrite_cancelled_status_with_late_failure(
def test_cancelled_runner_does_not_emit_late_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def scenario() -> None:
@ -243,20 +243,13 @@ def test_cancelled_runner_does_not_overwrite_cancelled_status_with_late_failure(
async def fail_after_cancel() -> RunSuccessOutcome:
nonlocal cancelled
cancelled = True
_ = await emit_run_cancelled(
sink,
run_id="run-cancelled",
reason="workflow_aborted",
message="workflow stopped",
)
raise RuntimeError("late model failure")
monkeypatch.setattr(runner, "_run_agent", fail_after_cancel)
await runner.run()
assert sink.statuses["run-cancelled"] == "cancelled"
assert sink.errors["run-cancelled"] == "workflow stopped"
assert [event.type for event in sink.events["run-cancelled"]] == ["run_started", "run_cancelled"]
assert "run-cancelled" not in sink.statuses
assert [event.type for event in sink.events["run-cancelled"]] == ["run_started"]
asyncio.run(scenario())
@ -924,6 +917,44 @@ def test_runner_does_not_classify_nested_timeout_as_agent_limit(monkeypatch: pyt
assert sink.statuses["run-provider-timeout"] == "failed"
def test_runner_captures_post_exit_snapshot_when_task_is_cancelled(monkeypatch: pytest.MonkeyPatch) -> None:
started = asyncio.Event()
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient, agent_run_id: str):
return TestModel(custom_output_text="unused") # pyright: ignore[reportReturnType]
class FakeAgent:
async def run(self, *_args: object, **_kwargs: object) -> None:
started.set()
await asyncio.Event().wait()
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", lambda *_args, **_kwargs: FakeAgent())
sink = InMemoryRunEventSink()
async def scenario() -> AgentRunRunner:
async with httpx.AsyncClient() as client:
runner = AgentRunRunner(
sink=sink,
request=_request(),
run_id="run-cancel-snapshot",
plugin_daemon_http_client=client,
dify_api_http_client=client,
)
task = asyncio.create_task(runner.run())
await asyncio.wait_for(started.wait(), timeout=1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
return runner
runner = asyncio.run(scenario())
assert runner.terminal_session_snapshot is not None
assert all(layer.lifecycle_state is LifecycleState.SUSPENDED for layer in runner.terminal_session_snapshot.layers)
assert [event.type for event in sink.events["run-cancel-snapshot"]] == ["run_started"]
def test_runner_emits_deferred_tool_call_and_persists_pending_history(monkeypatch: pytest.MonkeyPatch) -> None:
captured_output_types: list[object] = []
captured_user_prompts: list[object] = []
@ -2206,7 +2237,7 @@ def test_runner_with_empty_history_layer_uses_instructions_and_saves_full_histor
assert all(not isinstance(message, ModelRequest) or message.instructions is None for message in saved_history)
def test_runner_failure_with_history_layer_emits_failed_terminal_event_without_success_snapshot(
def test_runner_failure_with_history_layer_emits_post_exit_snapshot_without_new_history(
monkeypatch: pytest.MonkeyPatch,
) -> None:
model = RecordingTestModel(failure=RuntimeError("boom"))
@ -2239,6 +2270,10 @@ def test_runner_failure_with_history_layer_emits_failed_terminal_event_without_s
assert [event.type for event in sink.events["run-history-failure"]] == ["run_started", "run_failed"]
assert sink.statuses["run-history-failure"] == "failed"
terminal = sink.events["run-history-failure"][-1]
assert isinstance(terminal, RunFailedEvent)
assert terminal.data.session_snapshot is not None
assert _history_messages_from_snapshot(terminal.data.session_snapshot) == stored_history
assert request.session_snapshot is not None
assert _history_messages_from_snapshot(request.session_snapshot) == stored_history
@ -3015,6 +3050,10 @@ def test_runner_rejects_closed_session_snapshot_as_validation_error() -> None:
assert [event.type for event in sink.events["run-closed-snapshot"]] == ["run_started", "run_failed"]
assert sink.statuses["run-closed-snapshot"] == "failed"
terminal = sink.events["run-closed-snapshot"][-1]
assert isinstance(terminal, RunFailedEvent)
assert request.session_snapshot is not None
assert terminal.data.session_snapshot is None
def test_runner_treats_missing_runtime_dependency_as_validation_error() -> None:

View File

@ -9,16 +9,19 @@ from pydantic import JsonValue
from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot
from agenton.layers import LifecycleState
from dify_agent.protocol.schemas import (
RUN_EVENT_ADAPTER,
CancelRunRequest,
RunCancelledEvent,
RunCancelledEventData,
RunFailedEvent,
RunFailedEventData,
RunFailureType,
RunStartedEvent,
RunStatus,
RunSucceededEvent,
RunSucceededEventData,
utc_now,
)
from dify_agent.runtime.cancellation import RunCancellationIntent
from dify_agent.runtime.event_sink import RunFinalizationResult
from dify_agent.storage.redis_run_store import DEFAULT_RUN_RETENTION_SECONDS, RedisRunStore, RunNotFoundError
@ -27,12 +30,14 @@ class FakeRedis:
commands: list[tuple[object, ...]]
values: dict[str, object]
streams: dict[str, list[tuple[str, dict[str, object]]]]
eval_result: list[object] | None
def __init__(self) -> None:
self.commands = []
self.values = {}
self.streams = {}
self.stream_changed = asyncio.Event()
self.eval_result = None
async def set(self, key: str, value: object, *, ex: int | None = None) -> None:
self.commands.append(("set", key, value, ex))
@ -109,29 +114,9 @@ class FakeRedis:
async def eval(self, script: str, numkeys: int, *keys_and_args: object) -> list[object]:
self.commands.append(("eval", script, numkeys, *keys_and_args))
assert numkeys == 2
record_key = str(keys_and_args[0])
events_key = str(keys_and_args[1])
status = str(keys_and_args[2])
updated_at = str(keys_and_args[3])
has_error = str(keys_and_args[4]) == "1"
error = str(keys_and_args[5]) if has_error else None
has_error_type = str(keys_and_args[6]) == "1"
error_type = str(keys_and_args[7]) if has_error_type else None
payload = str(keys_and_args[8])
record_json = self.values.get(record_key)
if record_json is None:
return [-1, "", ""]
if isinstance(record_json, bytes):
record_json = record_json.decode()
record = json.loads(cast(str, record_json))
if record["status"] != "running":
return [0, record["status"], ""]
record.update({"status": status, "updated_at": updated_at, "error": error, "error_type": error_type})
event_id = self._append_stream_entry(events_key, {"payload": payload})
self.values[record_key] = json.dumps(record, separators=(",", ":"))
return [1, status, event_id]
if self.eval_result is None:
raise AssertionError("test must configure FakeRedis.eval_result")
return list(self.eval_result)
@staticmethod
def _is_after_min(event_id: str, min_id: str) -> bool:
@ -224,75 +209,76 @@ def test_get_run_accepts_legacy_record_without_error_type() -> None:
assert loaded.error_type is None
def test_finalize_run_atomically_writes_terminal_event_and_status() -> None:
def test_request_cancellation_maps_eval_result_and_arguments() -> None:
redis = FakeRedis()
store = RedisRunStore(redis, prefix="test", run_retention_seconds=60) # pyright: ignore[reportArgumentType]
record = asyncio.run(store.create_run())
redis.commands.clear()
event = RunCancelledEvent(
run_id=record.run_id,
data=RunCancelledEventData(reason="workflow_aborted", message="workflow stopped"),
redis.eval_result = [1, "running"]
status = asyncio.run(
store.request_cancellation(
"run-1",
CancelRunRequest(reason="workflow_aborted", message="workflow stopped"),
)
)
result = asyncio.run(store.finalize_run(event))
updated = asyncio.run(store.get_run(record.run_id))
assert status == "running"
eval_command = redis.commands[-1]
assert eval_command[0] == "eval"
assert eval_command[2] == 3
assert eval_command[3:6] == (
"test:runs:run-1:record",
"test:runs:run-1:cancel-intent",
"test:runs:run-1:events",
)
intent_payload = json.loads(cast(str, eval_command[6]))
assert intent_payload["reason"] == "workflow_aborted"
assert intent_payload["message"] == "workflow stopped"
assert eval_command[7] == "60"
assert result.applied is True
assert result.status == "cancelled"
assert result.event_id == "1-0"
assert updated.status == "cancelled"
assert updated.error == "workflow stopped"
assert updated.error_type is None
assert updated.updated_at == event.created_at
stream_entry_id, stream_fields = redis.streams[f"test:runs:{record.run_id}:events"][0]
assert stream_entry_id == result.event_id
payload = json.loads(cast(str, stream_fields["payload"]))
def test_finalize_cancellation_maps_eval_result_and_arguments() -> None:
redis = FakeRedis()
store = RedisRunStore(redis, prefix="test", run_retention_seconds=60) # pyright: ignore[reportArgumentType]
redis.eval_result = [1, "cancelled", "7-0"]
intent = RunCancellationIntent(
reason="workflow_aborted",
message="workflow stopped",
requested_at=utc_now(),
)
result = asyncio.run(
store.finalize_cancellation(
"run-1",
intent,
session_snapshot=CompositorSessionSnapshot(layers=[]),
)
)
assert result == RunFinalizationResult(applied=True, status="cancelled", event_id="7-0")
eval_command = redis.commands[-1]
assert eval_command[2] == 3
assert eval_command[3:6] == (
"test:runs:run-1:record",
"test:runs:run-1:cancel-intent",
"test:runs:run-1:events",
)
payload = json.loads(cast(str, eval_command[9]))
assert "id" not in payload
assert payload["type"] == "run_cancelled"
assert payload["data"] == {"reason": "workflow_aborted", "message": "workflow stopped"}
assert payload["created_at"] == event.created_at.isoformat().replace("+00:00", "Z")
eval_command = redis.commands[0]
assert eval_command[0] == "eval"
assert eval_command[2] == 2
assert eval_command[-1] == "60"
assert payload["data"] == {
"reason": "workflow_aborted",
"message": "workflow stopped",
"session_snapshot": {"schema_version": 1, "layers": []},
}
assert eval_command[10] == "60"
def test_finalize_run_rejects_a_second_terminal_without_appending_event() -> None:
redis = FakeRedis()
store = RedisRunStore(redis, prefix="test", run_retention_seconds=60) # pyright: ignore[reportArgumentType]
record = asyncio.run(store.create_run())
snapshot = CompositorSessionSnapshot(layers=[])
first = asyncio.run(
store.finalize_run(
RunSucceededEvent(
run_id=record.run_id,
data=RunSucceededEventData(output="done", session_snapshot=snapshot),
)
)
)
second = asyncio.run(
store.finalize_run(
RunCancelledEvent(
run_id=record.run_id,
data=RunCancelledEventData(reason="late_cancel"),
)
)
)
assert first.applied is True
assert second.applied is False
assert second.status == "succeeded"
assert second.event_id is None
assert len(redis.streams[f"test:runs:{record.run_id}:events"]) == 1
def test_finalize_failed_run_derives_error_and_timestamp_from_event() -> None:
def test_finalize_failed_run_maps_eval_result_and_arguments() -> None:
redis = FakeRedis()
store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
record = asyncio.run(store.create_run())
redis.eval_result = [1, "failed", "8-0"]
event = RunFailedEvent(
run_id=record.run_id,
run_id="run-1",
data=RunFailedEventData(
error="model failed",
error_type=RunFailureType.AGENT_RUN_LIMIT_EXCEEDED,
@ -301,117 +287,46 @@ def test_finalize_failed_run_derives_error_and_timestamp_from_event() -> None:
)
result = asyncio.run(store.finalize_run(event))
updated = asyncio.run(store.get_run(record.run_id))
assert result.applied is True
assert result.status == "failed"
assert updated.status == "failed"
assert updated.error == "model failed"
assert updated.error_type is RunFailureType.AGENT_RUN_LIMIT_EXCEEDED
assert updated.updated_at == event.created_at
stream_entry = redis.streams[f"test:runs:{record.run_id}:events"][0]
payload = json.loads(cast(str, stream_entry[1]["payload"]))
assert result == RunFinalizationResult(applied=True, status="failed", event_id="8-0")
eval_command = redis.commands[-1]
assert eval_command[3:6] == (
"test:runs:run-1:record",
"test:runs:run-1:events",
"test:runs:run-1:cancel-intent",
)
assert eval_command[6] == "failed"
assert eval_command[8:12] == ("1", "model failed", "1", "agent_run_limit_exceeded")
payload = json.loads(cast(str, eval_command[12]))
assert payload["data"]["error_type"] == "agent_run_limit_exceeded"
def test_two_store_instances_choose_exactly_one_terminal_winner() -> None:
redis = FakeRedis()
first_store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
second_store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
async def scenario() -> tuple[list[RunFinalizationResult], RunStatus, list[str]]:
record = await first_store.create_run()
snapshot = CompositorSessionSnapshot(layers=[])
results = await asyncio.gather(
first_store.finalize_run(
RunSucceededEvent(
run_id=record.run_id,
data=RunSucceededEventData(output="done", session_snapshot=snapshot),
)
),
second_store.finalize_run(
RunCancelledEvent(
run_id=record.run_id,
data=RunCancelledEventData(reason="concurrent_cancel"),
)
),
)
persisted = await first_store.get_run(record.run_id)
page = await second_store.get_events(record.run_id)
return list(results), persisted.status, [event.type for event in page.events]
results, status, event_types = asyncio.run(scenario())
assert sum(result.applied for result in results) == 1
assert len(event_types) == 1
assert (status, event_types[0]) in {
("succeeded", "run_succeeded"),
("cancelled", "run_cancelled"),
}
def test_failure_and_cancellation_compete_for_one_terminal() -> None:
redis = FakeRedis()
failure_store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
cancellation_store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
async def scenario() -> tuple[list[RunFinalizationResult], RunStatus, list[str]]:
record = await failure_store.create_run()
results = await asyncio.gather(
failure_store.finalize_run(
RunFailedEvent(
run_id=record.run_id,
data=RunFailedEventData(error="model failed", reason="model_error"),
)
),
cancellation_store.finalize_run(
RunCancelledEvent(
run_id=record.run_id,
data=RunCancelledEventData(reason="concurrent_cancel"),
)
),
)
persisted = await failure_store.get_run(record.run_id)
page = await cancellation_store.get_events(record.run_id)
return list(results), persisted.status, [event.type for event in page.events]
results, status, event_types = asyncio.run(scenario())
assert sum(result.applied for result in results) == 1
assert len(event_types) == 1
assert (status, event_types[0]) in {
("failed", "run_failed"),
("cancelled", "run_cancelled"),
}
def test_finalize_run_raises_when_record_is_missing() -> None:
def test_request_cancellation_raises_when_record_is_missing() -> None:
redis = FakeRedis()
store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
redis.eval_result = [-1, ""]
with pytest.raises(RunNotFoundError):
asyncio.run(
store.finalize_run(RunCancelledEvent(run_id="missing", data=RunCancelledEventData(reason="cancelled")))
)
asyncio.run(store.request_cancellation("missing", CancelRunRequest(reason="cancelled")))
def test_wait_for_cancellation_observes_terminal_record_before_starting() -> None:
redis = FakeRedis()
store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
async def scenario() -> bool:
async def scenario() -> object:
record = await store.create_run()
_ = await store.finalize_run(
RunCancelledEvent(run_id=record.run_id, data=RunCancelledEventData(reason="cancelled"))
)
redis.values[f"test:runs:{record.run_id}:record"] = record.model_copy(
update={"status": "cancelled"}
).model_dump_json()
redis.commands.clear()
return await store.wait_for_cancellation(record.run_id)
assert asyncio.run(scenario()) is True
assert asyncio.run(scenario()) is None
assert [command[0] for command in redis.commands] == ["xrevrange", "get"]
def test_wait_for_cancellation_covers_terminal_transition_during_initialization() -> None:
def test_wait_for_cancellation_covers_intent_transition_during_initialization() -> None:
class PausingRecordReadRedis(FakeRedis):
record_read_started: asyncio.Event
release_record_read: asyncio.Event
@ -432,62 +347,75 @@ def test_wait_for_cancellation_covers_terminal_transition_during_initialization(
redis = PausingRecordReadRedis()
observer_store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
cancelling_store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
async def scenario() -> bool:
async def scenario() -> object:
record = await observer_store.create_run()
observer = asyncio.create_task(observer_store.wait_for_cancellation(record.run_id))
await asyncio.wait_for(redis.record_read_started.wait(), timeout=1)
_ = await cancelling_store.finalize_run(
RunCancelledEvent(run_id=record.run_id, data=RunCancelledEventData(reason="cancelled"))
_ = redis._append_stream_entry(
f"test:runs:{record.run_id}:cancel-intent",
{
"payload": RunCancellationIntent(
reason="cancelled",
requested_at=utc_now(),
).model_dump_json()
},
)
redis.release_record_read.set()
return await asyncio.wait_for(observer, timeout=1)
assert asyncio.run(scenario()) is True
assert asyncio.run(scenario()) is not None
def test_wait_for_cancellation_advances_past_non_terminal_events() -> None:
redis = FakeRedis()
store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
async def scenario() -> bool:
async def scenario() -> object:
record = await store.create_run()
observer = asyncio.create_task(store.wait_for_cancellation(record.run_id))
await asyncio.sleep(0)
_ = await store.append_event(RunStartedEvent(run_id=record.run_id))
await asyncio.sleep(0)
_ = await store.finalize_run(
RunCancelledEvent(run_id=record.run_id, data=RunCancelledEventData(reason="cancelled"))
_ = redis._append_stream_entry(
f"test:runs:{record.run_id}:cancel-intent",
{
"payload": RunCancellationIntent(
reason="cancelled",
requested_at=utc_now(),
).model_dump_json()
},
)
return await asyncio.wait_for(observer, timeout=1)
assert asyncio.run(scenario()) is True
assert asyncio.run(scenario()) is not None
cursors = [command[1] for command in redis.commands if command[0] == "xread"]
assert any("0-0" in streams.values() for streams in cursors if isinstance(streams, dict))
assert any("1-0" in streams.values() for streams in cursors if isinstance(streams, dict))
def test_wait_for_cancellation_returns_false_when_success_wins() -> None:
def test_wait_for_cancellation_returns_none_when_success_wins() -> None:
redis = FakeRedis()
store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
async def scenario() -> bool:
async def scenario() -> object:
record = await store.create_run()
observer = asyncio.create_task(store.wait_for_cancellation(record.run_id))
await asyncio.sleep(0)
_ = await store.finalize_run(
RunSucceededEvent(
run_id=record.run_id,
data=RunSucceededEventData(
output="done",
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
)
event = RunSucceededEvent(
run_id=record.run_id,
data=RunSucceededEventData(
output="done",
session_snapshot=CompositorSessionSnapshot(layers=[]),
),
)
_ = redis._append_stream_entry(
f"test:runs:{record.run_id}:events",
{"payload": RUN_EVENT_ADAPTER.dump_json(event, exclude={"id"}).decode()},
)
return await asyncio.wait_for(observer, timeout=1)
assert asyncio.run(scenario()) is False
assert asyncio.run(scenario()) is None
def test_append_event_serializes_typed_event_without_id_and_expires_run_keys() -> None:
@ -530,19 +458,20 @@ def test_get_events_round_trips_run_succeeded_output_and_session_snapshot() -> N
async def scenario() -> tuple[str, RunSucceededEvent]:
record = await store.create_run()
result = await store.finalize_run(
RunSucceededEvent(
id="local-only",
run_id=record.run_id,
data=RunSucceededEventData(output=output, session_snapshot=session_snapshot),
)
event = RunSucceededEvent(
id="local-only",
run_id=record.run_id,
data=RunSucceededEventData(output=output, session_snapshot=session_snapshot),
)
event_id = redis._append_stream_entry(
f"test:runs:{record.run_id}:events",
{"payload": RUN_EVENT_ADAPTER.dump_json(event, exclude={"id"}).decode()},
)
assert result.event_id is not None
page = await store.get_events(record.run_id, after="0-0", limit=10)
decoded = page.events[0]
assert isinstance(decoded, RunSucceededEvent)
assert page.next_cursor == result.event_id
return result.event_id, decoded
assert page.next_cursor == event_id
return event_id, decoded
event_id, decoded = asyncio.run(scenario())
@ -559,7 +488,11 @@ def test_iter_events_ends_after_replaying_terminal_event(terminal_type: str) ->
async def scenario() -> list[str]:
record = await store.create_run()
_ = await store.append_event(RunStartedEvent(run_id=record.run_id))
_ = await store.finalize_run(_terminal_event(terminal_type, record.run_id))
terminal = _terminal_event(terminal_type, record.run_id)
_ = redis._append_stream_entry(
f"test:runs:{record.run_id}:events",
{"payload": RUN_EVENT_ADAPTER.dump_json(terminal, exclude={"id"}).decode()},
)
redis.commands.clear()
async def collect_events() -> list[str]:
@ -586,7 +519,11 @@ def test_iter_events_ends_after_live_terminal_event(terminal_type: str) -> None:
assert not next_event.done()
assert "xread" in [command[0] for command in redis.commands]
_ = await store.finalize_run(_terminal_event(terminal_type, record.run_id))
terminal = _terminal_event(terminal_type, record.run_id)
_ = redis._append_stream_entry(
f"test:runs:{record.run_id}:events",
{"payload": RUN_EVENT_ADAPTER.dump_json(terminal, exclude={"id"}).decode()},
)
event = await asyncio.wait_for(next_event, timeout=1)
with pytest.raises(StopAsyncIteration):
_ = await anext(events)