diff --git a/features/memory_service_clock_wait.feature b/features/memory_service_clock_wait.feature new file mode 100644 index 000000000..85b0883ab --- /dev/null +++ b/features/memory_service_clock_wait.feature @@ -0,0 +1,6 @@ +Feature: Memory service timestamp guards + + Scenario: Wait helper enforces deadline when clock freezes + Given the memory service coverage steps module + When I wait for clock advancement with a frozen clock and deadline 0.01 seconds + Then the wait helper raises a deadline assertion diff --git a/features/steps/memory_service_clock_wait_steps.py b/features/steps/memory_service_clock_wait_steps.py new file mode 100644 index 000000000..f611c2170 --- /dev/null +++ b/features/steps/memory_service_clock_wait_steps.py @@ -0,0 +1,62 @@ +"""Behave steps validating timestamp wait helper for memory service coverage.""" + +from __future__ import annotations + +import importlib +from datetime import UTC, datetime +from typing import Any + +from behave import given, then, when + + +@given("the memory service coverage steps module") +def step_get_memory_service_module(context: Any) -> None: + """Import the coverage steps module and ensure wait helper exists.""" + + module = importlib.import_module("features.steps.memory_service_coverage_steps") + context.memory_service_steps_module = module + assert hasattr( + module, "_wait_for_clock_advance" + ), "memory_service_coverage_steps._wait_for_clock_advance is required" + + +@when( + "I wait for clock advancement with a frozen clock and deadline {deadline:f} seconds" +) +def step_wait_with_frozen_clock(context: Any, deadline: float) -> None: + """Execute the wait helper with a clock that never advances.""" + + module = context.memory_service_steps_module + before = datetime.now(UTC) + context._wait_deadline = deadline + context._captured_error = None + context._original_datetime = module.datetime + + class FrozenDateTime: + """Stub datetime that always returns the same moment.""" + + UTC = module.UTC + + def __init__(self, value: datetime) -> None: + self._value = value + + def now(self, tz: object) -> datetime: + return self._value + + module.datetime = FrozenDateTime(before) + + try: + module._wait_for_clock_advance(before, deadline_secs=deadline) + except AssertionError as err: + context._captured_error = err + finally: + module.datetime = context._original_datetime + + +@then("the wait helper raises a deadline assertion") +def step_expect_deadline_assertion(context: Any) -> None: + """Validate the helper raised AssertionError with deadline details.""" + + assert context._captured_error is not None + message = str(context._captured_error) + assert f"within {context._wait_deadline}s" in message diff --git a/features/steps/memory_service_coverage_steps.py b/features/steps/memory_service_coverage_steps.py index 0ffcf079f..50a01c110 100644 --- a/features/steps/memory_service_coverage_steps.py +++ b/features/steps/memory_service_coverage_steps.py @@ -6,7 +6,7 @@ import asyncio import shutil import tempfile import time -from datetime import UTC +from datetime import UTC, datetime from pathlib import Path from typing import Any from unittest.mock import patch @@ -24,6 +24,18 @@ from cleveragents.application.services.memory_service import ( ) +def _wait_for_clock_advance(before: datetime, deadline_secs: float = 2.0) -> None: + """Spin until the UTC clock advances past ``before`` or raise on timeout.""" + + deadline = time.monotonic() + deadline_secs + while datetime.now(UTC) <= before: + if time.monotonic() > deadline: + raise AssertionError( + f"Clock did not advance past {before} within {deadline_secs}s" + ) + time.sleep(0.001) + + @given("a conversation adapter configured to return text values") def step_adapter_returning_text(context: Any) -> None: """Create an adapter that returns buffer strings.""" @@ -434,7 +446,7 @@ def step_have_tracked_file_entity(context: Any, name: str) -> None: @when("I track the same entity again") def step_track_same_entity(context: Any) -> None: """Track the same entity again to update it.""" - time.sleep(0.01) # Small delay to ensure time difference + _wait_for_clock_advance(datetime.now(UTC)) context.tracked_entity = context.memory_service.track_entity( context.tracked_entity.name, context.tracked_entity.entity_type ) @@ -503,9 +515,9 @@ def step_memory_service_entities_over_time(context: Any) -> None: # Track entities with small delays to ensure different timestamps context.memory_service.track_entity("oldest", EntityType.PROJECT) - time.sleep(0.01) + _wait_for_clock_advance(datetime.now(UTC)) context.memory_service.track_entity("middle", EntityType.PLAN) - time.sleep(0.01) + _wait_for_clock_advance(datetime.now(UTC)) context.memory_service.track_entity("newest", EntityType.FILE) @@ -760,7 +772,7 @@ def step_track_project_with_initial_metadata(context: Any, name: str) -> None: @when("I track the same project entity again with additional metadata") def step_track_same_entity_with_additional_metadata(context: Any) -> None: """Track the same entity with additional metadata to trigger update.""" - time.sleep(0.01) # Small delay to ensure time difference + _wait_for_clock_advance(datetime.now(UTC)) additional_metadata = {"status": "active", "priority": "high"} context.tracked_entity = context.memory_service.track_entity( context.entity_name, EntityType.PROJECT, additional_metadata