Files
crewAI/lib/crewai/tests/test_flow_persistence.py
Joao Moura 6e81617362 [EPD-196] Reset restore signal eagerly when a streaming session is created
- stream_events/astream (and thus kickoff/kickoff_async with stream=True)
  now reset last_restore_succeeded to None before returning the session,
  matching kickoff_async's reset-at-start semantics. Previously the
  property held the prior kickoff's stale True/False until frames were
  consumed (Bugbot round 3).
- The eager strict-raise path is unchanged: with raise_on_missing_state a
  miss still sets the signal to False before raising at session creation.
- Document streaming timing on the last_restore_succeeded property.
- Failing-first sync + async tests: stale True from a prior restore reads
  None right after obtaining a new session, then False after consumption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:43:39 -07:00

904 lines
30 KiB
Python

"""Test flow state persistence functionality."""
import os
from typing import Dict, List
import pytest
from crewai.flow.flow import Flow, FlowState, listen, start
from crewai.flow.persistence import persist
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
from pydantic import BaseModel
class TestState(FlowState):
"""Test state model with required id field."""
counter: int = 0
message: str = ""
def test_persist_decorator_saves_state(tmp_path, caplog):
"""Test that @persist decorator saves state in SQLite."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class TestFlow(Flow[Dict[str, str]]):
initial_state = dict()
@start()
@persist(persistence)
def init_step(self):
self.state["message"] = "Hello, World!"
self.state["id"] = "test-uuid" # Ensure we have an ID for persistence
flow = TestFlow(persistence=persistence)
flow.kickoff()
saved_state = persistence.load_state(flow.state["id"])
assert saved_state is not None
assert saved_state["message"] == "Hello, World!"
def test_structured_state_persistence(tmp_path):
"""Test persistence with Pydantic model state."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class StructuredFlow(Flow[TestState]):
initial_state = TestState
@start()
@persist(persistence)
def count_up(self):
self.state.counter += 1
self.state.message = f"Count is {self.state.counter}"
flow = StructuredFlow(persistence=persistence)
flow.kickoff()
saved_state = persistence.load_state(flow.state.id)
assert saved_state is not None
assert saved_state["counter"] == 1
assert saved_state["message"] == "Count is 1"
def test_flow_state_restoration(tmp_path):
"""Test restoring flow state from persistence with various restoration methods."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class RestorableFlow(Flow[TestState]):
@start()
@persist(persistence)
def set_message(self):
if self.state.message == "":
self.state.message = "Original message"
if self.state.counter == 0:
self.state.counter = 42
flow1 = RestorableFlow(persistence=persistence)
flow1.kickoff()
original_uuid = flow1.state.id
flow2 = RestorableFlow(persistence=persistence)
flow2.kickoff(inputs={"id": original_uuid, "counter": 43})
assert flow2.state.id == original_uuid
assert flow2.state.message == "Original message"
assert flow2.state.counter == 43
flow3 = RestorableFlow(persistence=persistence)
flow3.kickoff(inputs={"id": original_uuid, "message": "Updated message"})
assert flow3.state.id == original_uuid
assert flow3.state.counter == 43
assert flow3.state.message == "Updated message"
def test_multiple_method_persistence(tmp_path):
"""Test state persistence across multiple method executions."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class MultiStepFlow(Flow[TestState]):
@start()
@persist(persistence)
def step_1(self):
if self.state.counter == 1:
self.state.counter = 99999
self.state.message = "Step 99999"
else:
self.state.counter = 1
self.state.message = "Step 1"
@listen(step_1)
@persist(persistence)
def step_2(self):
if self.state.counter == 1:
self.state.counter = 2
self.state.message = "Step 2"
flow = MultiStepFlow(persistence=persistence)
flow.kickoff()
flow2 = MultiStepFlow(persistence=persistence)
flow2.kickoff(inputs={"id": flow.state.id})
final_state = flow2.state
assert final_state is not None
assert final_state.counter == 2
assert final_state.message == "Step 2"
class NoPersistenceMultiStepFlow(Flow[TestState]):
@start()
@persist(persistence)
def step_1(self):
if self.state.counter == 1:
self.state.counter = 99999
self.state.message = "Step 99999"
else:
self.state.counter = 1
self.state.message = "Step 1"
@listen(step_1)
def step_2(self):
if self.state.counter == 1:
self.state.counter = 2
self.state.message = "Step 2"
flow = NoPersistenceMultiStepFlow(persistence=persistence)
flow.kickoff()
flow2 = NoPersistenceMultiStepFlow(persistence=persistence)
flow2.kickoff(inputs={"id": flow.state.id})
final_state = flow2.state
assert final_state.counter == 99999
assert final_state.message == "Step 99999"
def test_persist_decorator_verbose_logging(tmp_path, caplog):
"""Test that @persist decorator's verbose parameter controls logging."""
# Set logging level to ensure we capture all logs
caplog.set_level("INFO")
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class QuietFlow(Flow[Dict[str, str]]):
initial_state = dict()
@start()
@persist(persistence)
def init_step(self):
self.state["message"] = "Hello, World!"
self.state["id"] = "test-uuid-1"
flow = QuietFlow(persistence=persistence)
flow.kickoff()
assert "Saving flow state" not in caplog.text
caplog.clear()
class VerboseFlow(Flow[Dict[str, str]]):
initial_state = dict()
@start()
@persist(persistence, verbose=True)
def init_step(self):
self.state["message"] = "Hello, World!"
self.state["id"] = "test-uuid-2"
flow = VerboseFlow(persistence=persistence)
flow.kickoff()
assert "Saving flow state" in caplog.text
def test_persistence_with_base_model(tmp_path):
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class Message(BaseModel):
role: str
type: str
content: str
class State(FlowState):
latest_message: Message | None = None
history: List[Message] = []
@persist(persistence)
class BaseModelFlow(Flow[State]):
initial_state = State(latest_message=None, history=[])
@start()
def init_step(self):
self.state.latest_message = Message(
role="user", type="text", content="Hello, World!"
)
self.state.history.append(self.state.latest_message)
flow = BaseModelFlow(persistence=persistence)
flow.kickoff()
latest_message = flow.state.latest_message
(message,) = flow.state.history
assert latest_message is not None
assert latest_message.role == "user"
assert latest_message.type == "text"
assert latest_message.content == "Hello, World!"
assert len(flow.state.history) == 1
assert message.role == "user"
assert message.type == "text"
assert message.content == "Hello, World!"
assert isinstance(flow.state, State)
def test_fork_with_restore_from_state_id(tmp_path):
"""Fork: restore_from_state_id hydrates state from source flow_uuid; new run gets a
fresh state.id; source's history is preserved (the fork's @persist writes go under
the new state.id, not the source's)."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class ForkableFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = ForkableFlow(persistence=persistence)
flow1.kickoff()
source_uuid = flow1.state.id
assert flow1.state.counter == 1
# Resume on the same uuid bumps counter to 2 in the SAME flow_uuid history.
flow1b = ForkableFlow(persistence=persistence)
flow1b.kickoff(inputs={"id": source_uuid})
assert flow1b.state.counter == 2
assert persistence.load_state(source_uuid)["counter"] == 2
# Fork: hydrate from source, but persist under a fresh state.id.
flow2 = ForkableFlow(persistence=persistence)
flow2.kickoff(restore_from_state_id=source_uuid)
# Fork has a different state.id from the source.
assert flow2.state.id != source_uuid
# Hydrated from source's latest snapshot (counter=2), then incremented to 3.
assert flow2.state.counter == 3
# Source's history is unchanged after the fork.
assert persistence.load_state(source_uuid)["counter"] == 2
# Fork's writes landed under its own state.id.
assert persistence.load_state(flow2.state.id)["counter"] == 3
def test_fork_with_pinned_state_id(tmp_path):
"""Fork into a pinned state.id (inputs.id supplied alongside restore_from_state_id):
the new run uses inputs.id as state.id and hydrates from restore_from_state_id."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class PinnableFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = PinnableFlow(persistence=persistence)
flow1.kickoff()
source_uuid = flow1.state.id
assert flow1.state.counter == 1
pinned_uuid = "pinned-fork-uuid-1234"
flow2 = PinnableFlow(persistence=persistence)
flow2.kickoff(
inputs={"id": pinned_uuid},
restore_from_state_id=source_uuid,
)
# state.id pinned to inputs.id, NOT the source uuid.
assert flow2.state.id == pinned_uuid
# Hydrated from source: counter started at 1, step incremented to 2.
assert flow2.state.counter == 2
# Source's history is unchanged.
assert persistence.load_state(source_uuid)["counter"] == 1
# Fork's writes are under the pinned uuid.
assert persistence.load_state(pinned_uuid)["counter"] == 2
def test_restore_from_state_id_not_found_silent_fallback(tmp_path):
"""Lookup miss on restore_from_state_id silently falls through to default behavior."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class FallbackFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow = FallbackFlow(persistence=persistence)
# No source UUID exists — should not raise.
flow.kickoff(restore_from_state_id="no-such-uuid")
# Default state path: counter starts at 0 and step increments to 1.
assert flow.state.counter == 1
# state.id is the auto-generated one, NOT the missing source.
assert flow.state.id != "no-such-uuid"
def test_restore_from_state_id_not_found_raises_when_strict(tmp_path):
"""raise_on_missing_state=True turns a restore_from_state_id lookup miss into a
ValueError instead of a silent fresh run."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class StrictFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow = StrictFlow(persistence=persistence)
with pytest.raises(ValueError) as excinfo:
flow.kickoff(
restore_from_state_id="no-such-uuid",
raise_on_missing_state=True,
)
msg = str(excinfo.value)
assert "restore_from_state_id" in msg
assert "no-such-uuid" in msg
# The flow never ran: state is untouched and the outcome signal reports the miss.
assert flow.state.counter == 0
assert flow.last_restore_succeeded is False
def test_restore_from_state_id_strict_with_valid_id_restores(tmp_path):
"""raise_on_missing_state=True does not change behavior when the source state
exists: hydrate from the snapshot, mint a fresh state.id, report success."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class StrictForkFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = StrictForkFlow(persistence=persistence)
flow1.kickoff()
source_uuid = flow1.state.id
assert flow1.state.counter == 1
flow2 = StrictForkFlow(persistence=persistence)
flow2.kickoff(
restore_from_state_id=source_uuid,
raise_on_missing_state=True,
)
assert flow2.state.id != source_uuid
assert flow2.state.counter == 2
assert flow2.last_restore_succeeded is True
def test_strict_restore_without_persistence_raises():
"""raise_on_missing_state=True with restore_from_state_id but no persistence
backend raises instead of silently skipping hydration."""
class NoPersistenceFlow(Flow[TestState]):
@start()
def step(self):
self.state.counter += 1
flow = NoPersistenceFlow()
with pytest.raises(ValueError) as excinfo:
flow.kickoff(
restore_from_state_id="some-uuid",
raise_on_missing_state=True,
)
msg = str(excinfo.value)
assert "restore_from_state_id" in msg
assert "persistence" in msg
assert flow.last_restore_succeeded is False
def test_stream_events_strict_restore_miss_raises_eagerly(tmp_path):
"""stream_events with raise_on_missing_state=True raises at session creation
time on a restore_from_state_id miss, not while consuming frames."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class StreamStrictFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow = StreamStrictFlow(persistence=persistence)
with pytest.raises(ValueError) as excinfo:
flow.stream_events(
restore_from_state_id="no-such-uuid",
raise_on_missing_state=True,
)
assert "no-such-uuid" in str(excinfo.value)
# Nothing ran.
assert flow.state.counter == 0
def test_kickoff_stream_strict_restore_miss_raises_eagerly(tmp_path):
"""kickoff with stream=True and raise_on_missing_state=True raises immediately
on a restore miss instead of returning a StreamSession."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class StreamingKickoffFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow = StreamingKickoffFlow(persistence=persistence)
flow.stream = True
with pytest.raises(ValueError) as excinfo:
flow.kickoff(
restore_from_state_id="no-such-uuid",
raise_on_missing_state=True,
)
assert "no-such-uuid" in str(excinfo.value)
assert flow.state.counter == 0
def test_stream_events_strict_restore_valid_id_streams(tmp_path):
"""stream_events with raise_on_missing_state=True streams normally when the
source state exists: hydrates, mints a fresh state.id, reports success."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class StreamForkFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = StreamForkFlow(persistence=persistence)
flow1.kickoff()
source_uuid = flow1.state.id
flow2 = StreamForkFlow(persistence=persistence)
with flow2.stream_events(
restore_from_state_id=source_uuid,
raise_on_missing_state=True,
) as stream:
list(stream.events)
assert flow2.state.id != source_uuid
assert flow2.state.counter == 2
assert flow2.last_restore_succeeded is True
@pytest.mark.asyncio
async def test_astream_strict_restore_miss_raises_eagerly(tmp_path):
"""astream and kickoff_async(stream=True) raise at call time on a restore miss
with raise_on_missing_state=True."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class AsyncStreamStrictFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow = AsyncStreamStrictFlow(persistence=persistence)
with pytest.raises(ValueError) as excinfo:
flow.astream(
restore_from_state_id="no-such-uuid",
raise_on_missing_state=True,
)
assert "no-such-uuid" in str(excinfo.value)
assert flow.state.counter == 0
flow2 = AsyncStreamStrictFlow(persistence=persistence)
flow2.stream = True
with pytest.raises(ValueError):
await flow2.kickoff_async(
restore_from_state_id="no-such-uuid",
raise_on_missing_state=True,
)
assert flow2.state.counter == 0
def test_stream_eager_strict_miss_sets_last_restore_succeeded_false(tmp_path):
"""The eager streaming pre-check must record the miss in
last_restore_succeeded before raising — including overwriting a stale True
from a previous successful restore."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class EagerSignalFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = EagerSignalFlow(persistence=persistence)
flow1.kickoff()
source_uuid = flow1.state.id
# Prime the signal with a successful restore first.
flow2 = EagerSignalFlow(persistence=persistence)
flow2.kickoff(restore_from_state_id=source_uuid)
assert flow2.last_restore_succeeded is True
# Eager streaming miss on the same instance: signal flips to False, not stale True.
with pytest.raises(ValueError):
flow2.stream_events(
restore_from_state_id="no-such-uuid",
raise_on_missing_state=True,
)
assert flow2.last_restore_succeeded is False
# Fresh instance failing in the astream pre-check: False, not None.
flow3 = EagerSignalFlow(persistence=persistence)
with pytest.raises(ValueError):
flow3.astream(
restore_from_state_id="no-such-uuid",
raise_on_missing_state=True,
)
assert flow3.last_restore_succeeded is False
# No-persistence eager failure records the miss too.
class NoPersistenceStreamFlow(Flow[TestState]):
@start()
def step(self):
self.state.counter += 1
flow4 = NoPersistenceStreamFlow()
with pytest.raises(ValueError):
flow4.stream_events(
restore_from_state_id="some-uuid",
raise_on_missing_state=True,
)
assert flow4.last_restore_succeeded is False
def test_stream_session_creation_resets_last_restore_succeeded(tmp_path):
"""Obtaining a streaming session resets last_restore_succeeded to None (no
outcome yet) instead of exposing the previous kickoff's stale value; the
signal then transitions to the new outcome as frames are consumed."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class StreamResetFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = StreamResetFlow(persistence=persistence)
flow1.kickoff()
source_uuid = flow1.state.id
# Prime a stale True from a successful non-streaming restore.
flow2 = StreamResetFlow(persistence=persistence)
flow2.kickoff(restore_from_state_id=source_uuid)
assert flow2.last_restore_succeeded is True
# New streaming kickoff with a different (missing) restore id: the deferred
# run has not evaluated the restore yet, so the signal must read None, not
# the stale True.
session = flow2.stream_events(restore_from_state_id="no-such-uuid")
assert flow2.last_restore_succeeded is None
with session:
list(session.events)
assert flow2.last_restore_succeeded is False
@pytest.mark.asyncio
async def test_astream_session_creation_resets_last_restore_succeeded(tmp_path):
"""astream session creation resets the restore signal to None before the
deferred run evaluates restore_from_state_id."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class AsyncStreamResetFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = AsyncStreamResetFlow(persistence=persistence)
await flow1.kickoff_async()
source_uuid = flow1.state.id
flow2 = AsyncStreamResetFlow(persistence=persistence)
await flow2.kickoff_async(restore_from_state_id=source_uuid)
assert flow2.last_restore_succeeded is True
stream = flow2.astream(restore_from_state_id="no-such-uuid")
assert flow2.last_restore_succeeded is None
async with stream:
_ = [frame async for frame in stream.events]
assert flow2.last_restore_succeeded is False
def test_stream_conflict_check_wins_over_strict_restore_miss(tmp_path):
"""stream_events/astream with both from_checkpoint and restore_from_state_id
raise the mutual-exclusion ValueError, not the restore-miss one, even with
raise_on_missing_state=True and a missing id (matches non-streaming precedence)."""
from crewai.state import CheckpointConfig
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class StreamConflictFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow = StreamConflictFlow(persistence=persistence)
with pytest.raises(ValueError) as excinfo:
flow.stream_events(
from_checkpoint=CheckpointConfig(),
restore_from_state_id="no-such-uuid",
raise_on_missing_state=True,
)
msg = str(excinfo.value)
assert "Cannot combine" in msg
assert "from_checkpoint" in msg
flow2 = StreamConflictFlow(persistence=persistence)
with pytest.raises(ValueError) as excinfo2:
flow2.astream(
from_checkpoint=CheckpointConfig(),
restore_from_state_id="no-such-uuid",
raise_on_missing_state=True,
)
assert "Cannot combine" in str(excinfo2.value)
def test_last_restore_succeeded_signal(tmp_path):
"""last_restore_succeeded is None when no restore was requested, False after a
silent miss, and True after a successful restore."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class SignalFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = SignalFlow(persistence=persistence)
assert flow1.last_restore_succeeded is None
flow1.kickoff()
# No restore requested: signal stays None.
assert flow1.last_restore_succeeded is None
source_uuid = flow1.state.id
# Silent miss (default non-strict): the run proceeds but the signal is False.
flow2 = SignalFlow(persistence=persistence)
flow2.kickoff(restore_from_state_id="no-such-uuid")
assert flow2.state.counter == 1
assert flow2.last_restore_succeeded is False
# Successful restore: the signal is True.
flow3 = SignalFlow(persistence=persistence)
flow3.kickoff(restore_from_state_id=source_uuid)
assert flow3.state.counter == 2
assert flow3.last_restore_succeeded is True
# A later kickoff without a restore request resets the signal to None.
flow3.kickoff()
assert flow3.last_restore_succeeded is None
def test_restore_from_state_id_none_is_no_op(tmp_path):
"""restore_from_state_id=None (default) preserves baseline kickoff behavior."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class BaselineFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow = BaselineFlow(persistence=persistence)
flow.kickoff(restore_from_state_id=None)
assert flow.state.counter == 1
def test_fork_conflict_with_from_checkpoint_raises():
"""Passing both from_checkpoint and restore_from_state_id raises ValueError, naming
both parameters."""
from crewai.state import CheckpointConfig
class ConflictFlow(Flow[TestState]):
@start()
def step(self):
pass
flow = ConflictFlow()
with pytest.raises(ValueError) as excinfo:
flow.kickoff(
from_checkpoint=CheckpointConfig(),
restore_from_state_id="some-uuid",
)
msg = str(excinfo.value)
assert "from_checkpoint" in msg
assert "restore_from_state_id" in msg
@pytest.mark.asyncio
async def test_fork_via_kickoff_async(tmp_path):
"""kickoff_async honors restore_from_state_id: hydrates from source, mints fresh
state.id, persists under the new id, source history preserved."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class AsyncForkableFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = AsyncForkableFlow(persistence=persistence)
await flow1.kickoff_async()
source_uuid = flow1.state.id
assert flow1.state.counter == 1
flow2 = AsyncForkableFlow(persistence=persistence)
await flow2.kickoff_async(restore_from_state_id=source_uuid)
assert flow2.state.id != source_uuid
assert flow2.state.counter == 2
assert persistence.load_state(source_uuid)["counter"] == 1
assert persistence.load_state(flow2.state.id)["counter"] == 2
@pytest.mark.asyncio
async def test_fork_via_akickoff(tmp_path):
"""akickoff is the public async alias and must accept restore_from_state_id with
the same semantics as kickoff_async."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class AkickoffForkableFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = AkickoffForkableFlow(persistence=persistence)
await flow1.akickoff()
source_uuid = flow1.state.id
assert flow1.state.counter == 1
flow2 = AkickoffForkableFlow(persistence=persistence)
await flow2.akickoff(restore_from_state_id=source_uuid)
assert flow2.state.id != source_uuid
assert flow2.state.counter == 2
assert persistence.load_state(source_uuid)["counter"] == 1
assert persistence.load_state(flow2.state.id)["counter"] == 2
@pytest.mark.asyncio
async def test_akickoff_pinned_fork(tmp_path):
"""akickoff with both inputs.id and restore_from_state_id pins state.id while
hydrating from the source."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class PinnableAsyncFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = PinnableAsyncFlow(persistence=persistence)
await flow1.akickoff()
source_uuid = flow1.state.id
pinned_uuid = "pinned-akickoff-fork-uuid"
flow2 = PinnableAsyncFlow(persistence=persistence)
await flow2.akickoff(
inputs={"id": pinned_uuid},
restore_from_state_id=source_uuid,
)
assert flow2.state.id == pinned_uuid
assert flow2.state.counter == 2
assert persistence.load_state(source_uuid)["counter"] == 1
assert persistence.load_state(pinned_uuid)["counter"] == 2
@pytest.mark.asyncio
async def test_restore_from_state_id_not_found_raises_when_strict_async(tmp_path):
"""kickoff_async and akickoff honor raise_on_missing_state on a lookup miss."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class AsyncStrictFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow = AsyncStrictFlow(persistence=persistence)
with pytest.raises(ValueError) as excinfo:
await flow.kickoff_async(
restore_from_state_id="no-such-uuid",
raise_on_missing_state=True,
)
assert "no-such-uuid" in str(excinfo.value)
assert flow.state.counter == 0
assert flow.last_restore_succeeded is False
flow_alias = AsyncStrictFlow(persistence=persistence)
with pytest.raises(ValueError):
await flow_alias.akickoff(
restore_from_state_id="no-such-uuid",
raise_on_missing_state=True,
)
assert flow_alias.last_restore_succeeded is False
@pytest.mark.asyncio
async def test_restore_from_state_id_strict_with_valid_id_restores_async(tmp_path):
"""kickoff_async with raise_on_missing_state=True restores normally when the
source state exists and reports success."""
db_path = os.path.join(tmp_path, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class AsyncStrictForkFlow(Flow[TestState]):
@start()
@persist(persistence)
def step(self):
self.state.counter += 1
flow1 = AsyncStrictForkFlow(persistence=persistence)
await flow1.kickoff_async()
source_uuid = flow1.state.id
flow2 = AsyncStrictForkFlow(persistence=persistence)
await flow2.kickoff_async(
restore_from_state_id=source_uuid,
raise_on_missing_state=True,
)
assert flow2.state.id != source_uuid
assert flow2.state.counter == 2
assert flow2.last_restore_succeeded is True
@pytest.mark.asyncio
async def test_akickoff_fork_conflict_with_from_checkpoint_raises():
"""akickoff must raise the same conflict ValueError as kickoff/kickoff_async when
both from_checkpoint and restore_from_state_id are set."""
from crewai.state import CheckpointConfig
class AsyncConflictFlow(Flow[TestState]):
@start()
def step(self):
pass
flow = AsyncConflictFlow()
with pytest.raises(ValueError) as excinfo:
await flow.akickoff(
from_checkpoint=CheckpointConfig(),
restore_from_state_id="some-uuid",
)
msg = str(excinfo.value)
assert "from_checkpoint" in msg
assert "restore_from_state_id" in msg