mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add more specific exceptions to Workflow (#3188)
* Add more specifc workflow exceptions * Fix tests * AI comments * Misc
This commit is contained in:
committed by
GitHub
Unverified
parent
99c5718696
commit
6c956ec596
@@ -57,6 +57,12 @@ from ._events import (
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from ._exceptions import (
|
||||
WorkflowCheckpointException,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowException,
|
||||
WorkflowRunnerException,
|
||||
)
|
||||
from ._executor import (
|
||||
Executor,
|
||||
handler,
|
||||
@@ -109,7 +115,11 @@ from ._viz import WorkflowViz
|
||||
from ._workflow import Workflow, WorkflowRunResult
|
||||
from ._workflow_builder import WorkflowBuilder
|
||||
from ._workflow_context import WorkflowContext
|
||||
from ._workflow_executor import SubWorkflowRequestMessage, SubWorkflowResponseMessage, WorkflowExecutor
|
||||
from ._workflow_executor import (
|
||||
SubWorkflowRequestMessage,
|
||||
SubWorkflowResponseMessage,
|
||||
WorkflowExecutor,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
@@ -185,17 +195,21 @@ __all__ = [
|
||||
"WorkflowAgent",
|
||||
"WorkflowBuilder",
|
||||
"WorkflowCheckpoint",
|
||||
"WorkflowCheckpointException",
|
||||
"WorkflowCheckpointSummary",
|
||||
"WorkflowContext",
|
||||
"WorkflowConvergenceException",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
"WorkflowEventSource",
|
||||
"WorkflowException",
|
||||
"WorkflowExecutor",
|
||||
"WorkflowFailedEvent",
|
||||
"WorkflowLifecycleEvent",
|
||||
"WorkflowOutputEvent",
|
||||
"WorkflowRunResult",
|
||||
"WorkflowRunState",
|
||||
"WorkflowRunnerException",
|
||||
"WorkflowStartedEvent",
|
||||
"WorkflowStatusEvent",
|
||||
"WorkflowValidationError",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from ..exceptions import AgentFrameworkException
|
||||
|
||||
|
||||
class WorkflowException(AgentFrameworkException):
|
||||
"""Base exception for workflow errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowRunnerException(WorkflowException):
|
||||
"""Base exception for workflow runner errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowConvergenceException(WorkflowRunnerException):
|
||||
"""Exception raised when a workflow runner fails to converge within the maximum iterations."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowCheckpointException(WorkflowRunnerException):
|
||||
"""Exception raised for errors related to workflow checkpoints."""
|
||||
|
||||
pass
|
||||
@@ -7,11 +7,20 @@ from collections.abc import AsyncGenerator, Sequence
|
||||
from typing import Any
|
||||
|
||||
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
|
||||
from ._checkpoint_encoding import DATACLASS_MARKER, MODEL_MARKER, decode_checkpoint_value
|
||||
from ._checkpoint_encoding import (
|
||||
DATACLASS_MARKER,
|
||||
MODEL_MARKER,
|
||||
decode_checkpoint_value,
|
||||
)
|
||||
from ._const import EXECUTOR_STATE_KEY
|
||||
from ._edge import EdgeGroup
|
||||
from ._edge_runner import EdgeRunner, create_edge_runner
|
||||
from ._events import SuperStepCompletedEvent, SuperStepStartedEvent, WorkflowEvent
|
||||
from ._exceptions import (
|
||||
WorkflowCheckpointException,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowRunnerException,
|
||||
)
|
||||
from ._executor import Executor
|
||||
from ._runner_context import (
|
||||
Message,
|
||||
@@ -72,7 +81,7 @@ class Runner:
|
||||
async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]:
|
||||
"""Run the workflow until no more messages are sent."""
|
||||
if self._running:
|
||||
raise RuntimeError("Runner is already running.")
|
||||
raise WorkflowRunnerException("Runner is already running.")
|
||||
|
||||
self._running = True
|
||||
try:
|
||||
@@ -134,12 +143,9 @@ class Runner:
|
||||
break
|
||||
|
||||
if self._iteration >= self._max_iterations and await self._ctx.has_messages():
|
||||
raise RuntimeError(f"Runner did not converge after {self._max_iterations} iterations.")
|
||||
raise WorkflowConvergenceException(f"Runner did not converge after {self._max_iterations} iterations.")
|
||||
|
||||
logger.info(f"Workflow completed after {self._iteration} supersteps")
|
||||
# TODO(@taochen): iteration is reset to zero, even in the event of a request info event.
|
||||
# Should iteration be preserved in the event of a request info event?
|
||||
self._iteration = 0
|
||||
self._resumed_from_checkpoint = False # Reset resume flag for next run
|
||||
finally:
|
||||
self._running = False
|
||||
@@ -212,7 +218,7 @@ class Runner:
|
||||
self,
|
||||
checkpoint_id: str,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
) -> bool:
|
||||
) -> None:
|
||||
"""Restore workflow state from a checkpoint.
|
||||
|
||||
Args:
|
||||
@@ -221,7 +227,10 @@ class Runner:
|
||||
runner context itself is not configured with checkpointing.
|
||||
|
||||
Returns:
|
||||
True if restoration was successful, False otherwise
|
||||
None on success.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException on failure.
|
||||
"""
|
||||
try:
|
||||
# Load the checkpoint
|
||||
@@ -231,18 +240,19 @@ class Runner:
|
||||
elif checkpoint_storage is not None:
|
||||
checkpoint = await checkpoint_storage.load_checkpoint(checkpoint_id)
|
||||
else:
|
||||
logger.warning("Context does not support checkpointing and no external storage was provided")
|
||||
return False
|
||||
raise WorkflowCheckpointException(
|
||||
"Cannot load checkpoint: no checkpointing configured in context or external storage provided."
|
||||
)
|
||||
|
||||
if not checkpoint:
|
||||
logger.error(f"Checkpoint {checkpoint_id} not found")
|
||||
return False
|
||||
raise WorkflowCheckpointException(f"Checkpoint {checkpoint_id} not found")
|
||||
|
||||
# Validate the loaded checkpoint against the workflow
|
||||
graph_hash = getattr(self, "graph_signature_hash", None)
|
||||
checkpoint_hash = (checkpoint.metadata or {}).get("graph_signature")
|
||||
if graph_hash and checkpoint_hash and graph_hash != checkpoint_hash:
|
||||
raise ValueError(
|
||||
raise WorkflowCheckpointException(
|
||||
"Workflow graph has changed since the checkpoint was created. "
|
||||
"Please rebuild the original workflow before resuming."
|
||||
)
|
||||
@@ -263,12 +273,11 @@ class Runner:
|
||||
self._mark_resumed(checkpoint.iteration_count)
|
||||
|
||||
logger.info(f"Successfully restored workflow from checkpoint: {checkpoint_id}")
|
||||
return True
|
||||
except ValueError:
|
||||
except WorkflowCheckpointException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restore from checkpoint {checkpoint_id}: {e}")
|
||||
return False
|
||||
raise WorkflowCheckpointException(f"Failed to restore from checkpoint {checkpoint_id}") from e
|
||||
|
||||
async def _save_executor_states(self) -> None:
|
||||
"""Populate executor state by calling checkpoint hooks on executors.
|
||||
@@ -309,7 +318,7 @@ class Runner:
|
||||
try:
|
||||
state_dict = await executor.on_checkpoint_save()
|
||||
except Exception as ex: # pragma: no cover
|
||||
raise ValueError(f"Executor {exec_id} on_checkpoint_save failed: {ex}") from ex
|
||||
raise WorkflowCheckpointException(f"Executor {exec_id} on_checkpoint_save failed") from ex
|
||||
|
||||
try:
|
||||
await self._set_executor_state(exec_id, state_dict)
|
||||
@@ -335,17 +344,19 @@ class Runner:
|
||||
|
||||
executor_states = await self._shared_state.get(EXECUTOR_STATE_KEY)
|
||||
if not isinstance(executor_states, dict):
|
||||
raise ValueError("Executor states in shared state is not a dictionary. Unable to restore.")
|
||||
raise WorkflowCheckpointException("Executor states in shared state is not a dictionary. Unable to restore.")
|
||||
|
||||
for executor_id, state in executor_states.items(): # pyright: ignore[reportUnknownVariableType]
|
||||
if not isinstance(executor_id, str):
|
||||
raise ValueError("Executor ID in executor states is not a string. Unable to restore.")
|
||||
raise WorkflowCheckpointException("Executor ID in executor states is not a string. Unable to restore.")
|
||||
if not isinstance(state, dict) or not all(isinstance(k, str) for k in state): # pyright: ignore[reportUnknownVariableType]
|
||||
raise ValueError(f"Executor state for {executor_id} is not a dict[str, Any]. Unable to restore.")
|
||||
raise WorkflowCheckpointException(
|
||||
f"Executor state for {executor_id} is not a dict[str, Any]. Unable to restore."
|
||||
)
|
||||
|
||||
executor = self._executors.get(executor_id)
|
||||
if not executor:
|
||||
raise ValueError(f"Executor {executor_id} not found during state restoration.")
|
||||
raise WorkflowCheckpointException(f"Executor {executor_id} not found during state restoration.")
|
||||
|
||||
# Try backward compatibility behavior first
|
||||
# TODO(@taochen): Remove backward compatibility
|
||||
@@ -358,7 +369,7 @@ class Runner:
|
||||
await maybe # type: ignore[arg-type]
|
||||
restored = True
|
||||
except Exception as ex: # pragma: no cover - defensive
|
||||
raise ValueError(f"Executor {executor_id} restore_state failed: {ex}") from ex
|
||||
raise WorkflowCheckpointException(f"Executor {executor_id} restore_state failed") from ex
|
||||
|
||||
if not restored:
|
||||
# Try the updated behavior only if backward compatibility did not restore
|
||||
@@ -366,7 +377,7 @@ class Runner:
|
||||
await executor.on_checkpoint_restore(state) # pyright: ignore[reportUnknownArgumentType]
|
||||
restored = True
|
||||
except Exception as ex: # pragma: no cover - defensive
|
||||
raise ValueError(f"Executor {executor_id} on_checkpoint_restore failed: {ex}") from ex
|
||||
raise WorkflowCheckpointException(f"Executor {executor_id} on_checkpoint_restore failed") from ex
|
||||
|
||||
if not restored:
|
||||
logger.debug(f"Executor {executor_id} does not support state restoration; skipping.")
|
||||
@@ -409,7 +420,7 @@ class Runner:
|
||||
existing_states = {}
|
||||
|
||||
if not isinstance(existing_states, dict):
|
||||
raise ValueError("Existing executor states in shared state is not a dictionary.")
|
||||
raise WorkflowCheckpointException("Existing executor states in shared state is not a dictionary.")
|
||||
|
||||
existing_states[executor_id] = state
|
||||
await self._shared_state.set(EXECUTOR_STATE_KEY, existing_states)
|
||||
|
||||
@@ -414,16 +414,7 @@ class InProcRunnerContext:
|
||||
self._messages.clear()
|
||||
messages_data = checkpoint.messages
|
||||
for source_id, message_list in messages_data.items():
|
||||
self._messages[source_id] = [
|
||||
Message(
|
||||
data=decode_checkpoint_value(msg.get("data")),
|
||||
source_id=msg.get("source_id", ""),
|
||||
target_id=msg.get("target_id"),
|
||||
trace_contexts=msg.get("trace_contexts"),
|
||||
source_span_ids=msg.get("source_span_ids"),
|
||||
)
|
||||
for msg in message_list
|
||||
]
|
||||
self._messages[source_id] = [Message.from_dict(msg) for msg in message_list]
|
||||
|
||||
# Restore pending request info events
|
||||
self._pending_request_info_events.clear()
|
||||
|
||||
@@ -419,10 +419,7 @@ class Workflow(DictConvertible):
|
||||
"or build workflow with WorkflowBuilder.with_checkpointing(checkpoint_storage)."
|
||||
)
|
||||
|
||||
restored = await self._runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage)
|
||||
|
||||
if not restored:
|
||||
raise RuntimeError(f"Failed to restore from checkpoint: {checkpoint_id}")
|
||||
await self._runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage)
|
||||
|
||||
# Handle initial message
|
||||
elif message is not None:
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import WorkflowBuilder, WorkflowContext, WorkflowRunState, WorkflowStatusEvent, handler
|
||||
from agent_framework import (
|
||||
WorkflowBuilder,
|
||||
WorkflowCheckpointException,
|
||||
WorkflowContext,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
|
||||
from agent_framework._workflows._executor import Executor
|
||||
|
||||
@@ -43,7 +50,7 @@ async def test_resume_fails_when_graph_mismatch() -> None:
|
||||
# Build a structurally different workflow (different finish executor id)
|
||||
mismatched_workflow = build_workflow(storage, finish_id="finish_alt")
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow graph has changed"):
|
||||
with pytest.raises(WorkflowCheckpointException, match="Workflow graph has changed"):
|
||||
_ = [
|
||||
event
|
||||
async for event in mismatched_workflow.run_stream(
|
||||
|
||||
@@ -31,6 +31,7 @@ from agent_framework import (
|
||||
TextContent,
|
||||
Workflow,
|
||||
WorkflowCheckpoint,
|
||||
WorkflowCheckpointException,
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
@@ -341,7 +342,8 @@ async def test_magentic_orchestrator_round_limit_produces_partial_result():
|
||||
events.append(ev)
|
||||
|
||||
idle_status = next(
|
||||
(e for e in events if isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE), None
|
||||
(e for e in events if isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE),
|
||||
None,
|
||||
)
|
||||
assert idle_status is not None
|
||||
# Check that we got workflow output via WorkflowOutputEvent
|
||||
@@ -584,7 +586,9 @@ async def _collect_agent_responses_setup(participant: AgentProtocol) -> list[Cha
|
||||
if isinstance(ev, AgentRunUpdateEvent):
|
||||
captured.append(
|
||||
ChatMessage(
|
||||
role=ev.data.role or Role.ASSISTANT, text=ev.data.text or "", author_name=ev.data.author_name
|
||||
role=ev.data.role or Role.ASSISTANT,
|
||||
text=ev.data.text or "",
|
||||
author_name=ev.data.author_name,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -604,7 +608,9 @@ async def test_agent_executor_invoke_with_assistants_client_messages():
|
||||
assert any((m.author_name == agent.name and "ok" in (m.text or "")) for m in captured)
|
||||
|
||||
|
||||
async def _collect_checkpoints(storage: InMemoryCheckpointStorage) -> list[WorkflowCheckpoint]:
|
||||
async def _collect_checkpoints(
|
||||
storage: InMemoryCheckpointStorage,
|
||||
) -> list[WorkflowCheckpoint]:
|
||||
checkpoints = await storage.list_checkpoints()
|
||||
assert checkpoints
|
||||
checkpoints.sort(key=lambda cp: cp.timestamp)
|
||||
@@ -719,7 +725,7 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
|
||||
.build()
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow graph has changed"):
|
||||
with pytest.raises(WorkflowCheckpointException, match="Workflow graph has changed"):
|
||||
async for _ in renamed_workflow.run_stream(
|
||||
checkpoint_id=target_checkpoint.checkpoint_id, # type: ignore[reportUnknownMemberType]
|
||||
):
|
||||
@@ -760,7 +766,8 @@ async def test_magentic_stall_and_reset_reach_limits():
|
||||
events.append(ev)
|
||||
|
||||
idle_status = next(
|
||||
(e for e in events if isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE), None
|
||||
(e for e in events if isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE),
|
||||
None,
|
||||
)
|
||||
assert idle_status is not None
|
||||
output_event = next((e for e in events if isinstance(e, WorkflowOutputEvent)), None)
|
||||
@@ -799,7 +806,10 @@ async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None:
|
||||
"""Test that runtime checkpoint storage overrides build-time configuration."""
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as temp_dir1,
|
||||
tempfile.TemporaryDirectory() as temp_dir2,
|
||||
):
|
||||
from agent_framework._workflows._checkpoint import FileCheckpointStorage
|
||||
|
||||
buildtime_storage = FileCheckpointStorage(temp_dir1)
|
||||
|
||||
@@ -10,15 +10,21 @@ from agent_framework import (
|
||||
AgentResponse,
|
||||
Executor,
|
||||
WorkflowContext,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunnerException,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
from agent_framework._workflows._edge import SingleEdgeGroup
|
||||
from agent_framework._workflows._runner import Runner
|
||||
from agent_framework._workflows._runner_context import InProcRunnerContext, Message, RunnerContext
|
||||
from agent_framework._workflows._runner_context import (
|
||||
InProcRunnerContext,
|
||||
Message,
|
||||
RunnerContext,
|
||||
)
|
||||
from agent_framework._workflows._shared_state import SharedState
|
||||
|
||||
|
||||
@@ -52,7 +58,10 @@ def test_create_runner():
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
|
||||
runner = Runner(edge_groups, executors, shared_state=SharedState(), ctx=InProcRunnerContext())
|
||||
|
||||
@@ -70,7 +79,10 @@ async def test_runner_run_until_convergence():
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
shared_state = SharedState()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
@@ -90,6 +102,9 @@ async def test_runner_run_until_convergence():
|
||||
|
||||
assert result is not None and result == 10
|
||||
|
||||
# iteration count shouldn't be reset after convergence
|
||||
assert runner._iteration == 10 # type: ignore
|
||||
|
||||
|
||||
async def test_runner_run_until_convergence_not_completed():
|
||||
"""Test running the runner with a simple workflow."""
|
||||
@@ -102,7 +117,10 @@ async def test_runner_run_until_convergence_not_completed():
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
shared_state = SharedState()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
@@ -114,7 +132,10 @@ async def test_runner_run_until_convergence_not_completed():
|
||||
shared_state, # shared_state
|
||||
ctx, # runner_context
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Runner did not converge after 5 iterations."):
|
||||
with pytest.raises(
|
||||
WorkflowConvergenceException,
|
||||
match="Runner did not converge after 5 iterations.",
|
||||
):
|
||||
async for event in runner.run_until_convergence():
|
||||
assert not isinstance(event, WorkflowStatusEvent) or event.state != WorkflowRunState.IDLE
|
||||
|
||||
@@ -130,7 +151,10 @@ async def test_runner_already_running():
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
shared_state = SharedState()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
@@ -143,7 +167,7 @@ async def test_runner_already_running():
|
||||
ctx, # runner_context
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Runner is already running."):
|
||||
with pytest.raises(WorkflowRunnerException, match="Runner is already running."):
|
||||
|
||||
async def _run():
|
||||
async for _ in runner.run_until_convergence():
|
||||
|
||||
@@ -25,7 +25,9 @@ from agent_framework import (
|
||||
Role,
|
||||
TextContent,
|
||||
WorkflowBuilder,
|
||||
WorkflowCheckpointException,
|
||||
WorkflowContext,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowEvent,
|
||||
WorkflowOutputEvent,
|
||||
WorkflowRunState,
|
||||
@@ -143,7 +145,7 @@ async def test_workflow_run_stream_not_completed():
|
||||
.build()
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
with pytest.raises(WorkflowConvergenceException):
|
||||
async for _ in workflow.run_stream(NumberMessage(data=0)):
|
||||
pass
|
||||
|
||||
@@ -181,7 +183,7 @@ async def test_workflow_run_not_completed():
|
||||
.build()
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
with pytest.raises(WorkflowConvergenceException):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
|
||||
@@ -289,7 +291,9 @@ async def test_workflow_with_checkpointing_enabled(simple_executor: Executor):
|
||||
assert result is not None
|
||||
|
||||
|
||||
async def test_workflow_checkpointing_not_enabled_for_external_restore(simple_executor: Executor):
|
||||
async def test_workflow_checkpointing_not_enabled_for_external_restore(
|
||||
simple_executor: Executor,
|
||||
):
|
||||
"""Test that external checkpoint restoration fails when workflow doesn't support checkpointing."""
|
||||
# Build workflow WITHOUT checkpointing
|
||||
workflow = (
|
||||
@@ -308,7 +312,9 @@ async def test_workflow_checkpointing_not_enabled_for_external_restore(simple_ex
|
||||
assert "either provide checkpoint_storage parameter" in str(e)
|
||||
|
||||
|
||||
async def test_workflow_run_stream_from_checkpoint_no_checkpointing_enabled(simple_executor: Executor):
|
||||
async def test_workflow_run_stream_from_checkpoint_no_checkpointing_enabled(
|
||||
simple_executor: Executor,
|
||||
):
|
||||
# Build workflow WITHOUT checkpointing
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
@@ -327,7 +333,9 @@ async def test_workflow_run_stream_from_checkpoint_no_checkpointing_enabled(simp
|
||||
assert "either provide checkpoint_storage parameter" in str(e)
|
||||
|
||||
|
||||
async def test_workflow_run_stream_from_checkpoint_invalid_checkpoint(simple_executor: Executor):
|
||||
async def test_workflow_run_stream_from_checkpoint_invalid_checkpoint(
|
||||
simple_executor: Executor,
|
||||
):
|
||||
"""Test that attempting to restore from a non-existent checkpoint fails appropriately."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
@@ -345,12 +353,14 @@ async def test_workflow_run_stream_from_checkpoint_invalid_checkpoint(simple_exe
|
||||
try:
|
||||
async for _ in workflow.run_stream(checkpoint_id="nonexistent_checkpoint_id"):
|
||||
pass
|
||||
raise AssertionError("Expected RuntimeError to be raised")
|
||||
except RuntimeError as e:
|
||||
assert "Failed to restore from checkpoint" in str(e)
|
||||
raise AssertionError("Expected WorkflowCheckpointException to be raised")
|
||||
except WorkflowCheckpointException as e:
|
||||
assert str(e) == "Checkpoint nonexistent_checkpoint_id not found"
|
||||
|
||||
|
||||
async def test_workflow_run_stream_from_checkpoint_with_external_storage(simple_executor: Executor):
|
||||
async def test_workflow_run_stream_from_checkpoint_with_external_storage(
|
||||
simple_executor: Executor,
|
||||
):
|
||||
"""Test that external checkpoint storage can be provided for restoration."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
@@ -416,7 +426,9 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
|
||||
assert hasattr(result, "get_outputs") # Should have WorkflowRunResult methods
|
||||
|
||||
|
||||
async def test_workflow_run_stream_from_checkpoint_with_responses(simple_executor: Executor):
|
||||
async def test_workflow_run_stream_from_checkpoint_with_responses(
|
||||
simple_executor: Executor,
|
||||
):
|
||||
"""Test that workflow can be resumed from checkpoint with pending RequestInfoEvents."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
@@ -475,7 +487,9 @@ class StateTrackingExecutor(Executor):
|
||||
|
||||
@handler
|
||||
async def handle_message(
|
||||
self, message: StateTrackingMessage, ctx: WorkflowContext[StateTrackingMessage, list[str]]
|
||||
self,
|
||||
message: StateTrackingMessage,
|
||||
ctx: WorkflowContext[StateTrackingMessage, list[str]],
|
||||
) -> None:
|
||||
"""Handle the message and track it in shared state."""
|
||||
# Get existing messages from shared state
|
||||
@@ -537,7 +551,9 @@ async def test_workflow_multiple_runs_no_state_collision():
|
||||
assert outputs1[0] != outputs3[0]
|
||||
|
||||
|
||||
async def test_workflow_checkpoint_runtime_only_configuration(simple_executor: Executor):
|
||||
async def test_workflow_checkpoint_runtime_only_configuration(
|
||||
simple_executor: Executor,
|
||||
):
|
||||
"""Test that checkpointing can be configured ONLY at runtime, not at build time."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
storage = FileCheckpointStorage(temp_dir)
|
||||
@@ -574,12 +590,20 @@ async def test_workflow_checkpoint_runtime_only_configuration(simple_executor: E
|
||||
checkpoint_id=resume_checkpoint.checkpoint_id, checkpoint_storage=storage
|
||||
)
|
||||
assert result_resumed is not None
|
||||
assert result_resumed.get_final_state() in (WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS)
|
||||
assert result_resumed.get_final_state() in (
|
||||
WorkflowRunState.IDLE,
|
||||
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
|
||||
)
|
||||
|
||||
|
||||
async def test_workflow_checkpoint_runtime_overrides_buildtime(simple_executor: Executor):
|
||||
async def test_workflow_checkpoint_runtime_overrides_buildtime(
|
||||
simple_executor: Executor,
|
||||
):
|
||||
"""Test that runtime checkpoint storage overrides build-time configuration."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir1, tempfile.TemporaryDirectory() as temp_dir2:
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as temp_dir1,
|
||||
tempfile.TemporaryDirectory() as temp_dir2,
|
||||
):
|
||||
buildtime_storage = FileCheckpointStorage(temp_dir1)
|
||||
runtime_storage = FileCheckpointStorage(temp_dir2)
|
||||
|
||||
@@ -740,7 +764,10 @@ async def test_workflow_concurrent_execution_prevention():
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# Try to start a second concurrent execution - this should fail
|
||||
with pytest.raises(RuntimeError, match="Workflow is already running. Concurrent executions are not allowed."):
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
# Wait for the first task to complete
|
||||
@@ -773,7 +800,10 @@ async def test_workflow_concurrent_execution_prevention_streaming():
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
# Try to start a second concurrent execution - this should fail
|
||||
with pytest.raises(RuntimeError, match="Workflow is already running. Concurrent executions are not allowed."):
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
# Wait for the first task to complete
|
||||
@@ -803,10 +833,16 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods():
|
||||
await asyncio.sleep(0.02) # Let it start
|
||||
|
||||
# Try different execution methods - all should fail
|
||||
with pytest.raises(RuntimeError, match="Workflow is already running. Concurrent executions are not allowed."):
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
with pytest.raises(RuntimeError, match="Workflow is already running. Concurrent executions are not allowed."):
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
):
|
||||
async for _ in workflow.run_stream(NumberMessage(data=0)):
|
||||
break
|
||||
|
||||
@@ -923,7 +959,9 @@ async def test_workflow_run_parameter_validation(simple_executor: Executor) -> N
|
||||
pass
|
||||
|
||||
|
||||
async def test_workflow_run_stream_parameter_validation(simple_executor: Executor) -> None:
|
||||
async def test_workflow_run_stream_parameter_validation(
|
||||
simple_executor: Executor,
|
||||
) -> None:
|
||||
"""Test run_stream() specific parameter validation scenarios."""
|
||||
workflow = WorkflowBuilder().add_edge(simple_executor, simple_executor).set_start_executor(simple_executor).build()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user