fix(actor): repair list_actors lock-filter test fixtures (#8588)
Address CI lint + unit_tests failures on the namespace-lock test suite:
- Step file: drop unused imports (yaml, ExitStack, ValidationError) and
unused noqa directives flagged by ruff F401/RUF100; rewrite the
collected-errors list to use iterable unpacking (RUF005); register the
"I create an ActorLoader with initial actors from multiple namespaces"
step under both @given and @when so scenario 3 is no longer reported as
undefined; remove the dead _error_collector inner function the reviewer
flagged.
- Step regex fix: the concurrent-modifications step pattern ended with
'' (two single quotes) instead of `` (two backticks), so it never
matched the feature file's `(triggering ``discover()``)` literal.
- Test fixtures: add the required `description` field to _make_actor_yaml
in both the BDD step file and tests/actor/test_loader_list_actors_thread_safety.py
so ActorConfigSchema validation passes (previously every scenario
errored at discover() with "description: Field required").
- Concurrent worker names: collapse three-slash actor names like
"conc/ns{i % 2}/concurrent_{i}" to the single-slash form
"conc/concurrent_{i}" required by the schema's namespaced-name rule.
- Test file: drop unused `yaml` import and three unused F841 assignments
in _list_worker; apply ruff format.
- Feature file: switch the @issue_8660 TDD tag to @tdd_issue_8588 to
match the CONTRIBUTING.md tag convention for the bug issue this PR
closes.
ISSUES CLOSED: #8588
This commit is contained in:
@@ -9,20 +9,18 @@ from __future__ import annotations
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from contextlib import ExitStack
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
|
||||||
from behave import given, then, when # type: ignore[attr-defined]
|
from behave import given, then, when # type: ignore[attr-defined]
|
||||||
|
|
||||||
from cleveragents.actor.loader import ActorLoader
|
from cleveragents.actor.loader import ActorLoader
|
||||||
from cleveragents.core.exceptions import ValidationError
|
|
||||||
|
|
||||||
|
|
||||||
def _make_actor_yaml(name: str, provider: str = "openai", model: str = "gpt-4") -> str:
|
def _make_actor_yaml(name: str, provider: str = "openai", model: str = "gpt-4") -> str:
|
||||||
return f"""name: {name}
|
return f"""name: {name}
|
||||||
type: llm
|
type: llm
|
||||||
|
description: Test actor {name}
|
||||||
provider: {provider}
|
provider: {provider}
|
||||||
model: {model}
|
model: {model}
|
||||||
tools: []
|
tools: []
|
||||||
@@ -55,9 +53,9 @@ def _setup_actor_dir() -> tuple[str, Path]:
|
|||||||
|
|
||||||
|
|
||||||
@given("a temporary directory with multiple namespace groups of actor YAML files")
|
@given("a temporary directory with multiple namespace groups of actor YAML files")
|
||||||
def step_impl(context: Any) -> None:
|
def step_given_actor_dir(context: Any) -> None:
|
||||||
"""Set up temp directory with actors from two namespaces."""
|
"""Set up temp directory with actors from two namespaces."""
|
||||||
context._actor_tmp, context._actor_root = _setup_actor_dir()
|
context._actor_tmp, context._actor_root = _setup_actor_dir() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
@given("the loader discovers all actors")
|
@given("the loader discovers all actors")
|
||||||
@@ -68,11 +66,12 @@ def step_when_discover_all(context: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@given("I create an ActorLoader with initial actors from multiple namespaces")
|
@given("I create an ActorLoader with initial actors from multiple namespaces")
|
||||||
def step_impl(context: Any) -> None:
|
@when("I create an ActorLoader with initial actors from multiple namespaces")
|
||||||
|
def step_given_loader_with_initial(context: Any) -> None:
|
||||||
"""Set up a loader and seed it with actors."""
|
"""Set up a loader and seed it with actors."""
|
||||||
loader = ActorLoader(search_roots=[context._actor_root]) # type: ignore[attr-defined]
|
loader = ActorLoader(search_roots=[context._actor_root]) # type: ignore[attr-defined]
|
||||||
loader.discover()
|
loader.discover()
|
||||||
context.actor_loader = loader
|
context.actor_loader = loader # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
# ------- When steps --------------------------------------------------------
|
# ------- When steps --------------------------------------------------------
|
||||||
@@ -83,15 +82,15 @@ def step_when_create_loader(context: Any) -> None:
|
|||||||
root: Path = context._actor_root # type: ignore[attr-defined]
|
root: Path = context._actor_root # type: ignore[attr-defined]
|
||||||
loader = ActorLoader(search_roots=[root])
|
loader = ActorLoader(search_roots=[root])
|
||||||
loader.discover()
|
loader.discover()
|
||||||
context.actor_loader = loader
|
context.actor_loader = loader # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
@when(
|
@when(
|
||||||
"another thread concurrently adds and removes YAML files "
|
"another thread concurrently adds and removes YAML files "
|
||||||
"(triggering ``discover()'')"
|
"(triggering ``discover()``)"
|
||||||
)
|
)
|
||||||
def step_when_concurrent_modifications(context: Any) -> None:
|
def step_when_concurrent_modifications(context: Any) -> None:
|
||||||
"""Start a background thread that modifies actor files."""
|
"""Define a background thread that mutates actor files."""
|
||||||
root: Path = context._actor_root # type: ignore[attr-defined]
|
root: Path = context._actor_root # type: ignore[attr-defined]
|
||||||
|
|
||||||
def _concurrent_worker() -> None:
|
def _concurrent_worker() -> None:
|
||||||
@@ -99,19 +98,20 @@ def step_when_concurrent_modifications(context: Any) -> None:
|
|||||||
try:
|
try:
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
tmp_fpath = root / f"conc_{i}.yaml"
|
tmp_fpath = root / f"conc_{i}.yaml"
|
||||||
tmp_fpath.write_text(
|
tmp_fpath.write_text(_make_actor_yaml(f"conc/concurrent_{i}"))
|
||||||
_make_actor_yaml(f"conc/ns{i % 2}/concurrent_{i}")
|
|
||||||
)
|
|
||||||
loader = ActorLoader(search_roots=[root])
|
loader = ActorLoader(search_roots=[root])
|
||||||
loader.discover()
|
loader.discover()
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
if tmp_fpath.exists():
|
if tmp_fpath.exists():
|
||||||
tmp_fpath.unlink(missing_ok=True)
|
tmp_fpath.unlink(missing_ok=True)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
context._concurrent_errors = getattr(context, "_concurrent_errors", []) # type: ignore[attr-defined]
|
errors = getattr(context, "_concurrent_errors", []) # type: ignore[attr-defined]
|
||||||
context._concurrent_errors.append(exc) # type: ignore[attr-defined]
|
errors.append(exc)
|
||||||
|
context._concurrent_errors = errors # type: ignore[attr-defined]
|
||||||
|
|
||||||
context._worker_thread = threading.Thread(target=_concurrent_worker, daemon=True) # type: ignore[attr-defined]
|
context._worker_thread = threading.Thread( # type: ignore[attr-defined]
|
||||||
|
target=_concurrent_worker, daemon=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@when("a worker repeatedly calls ``list_actors(namespace=...)`` in parallel")
|
@when("a worker repeatedly calls ``list_actors(namespace=...)`` in parallel")
|
||||||
@@ -120,27 +120,18 @@ def step_when_parallel_list(context: Any) -> None:
|
|||||||
loader: ActorLoader = context.actor_loader # type: ignore[attr-defined]
|
loader: ActorLoader = context.actor_loader # type: ignore[attr-defined]
|
||||||
|
|
||||||
def _list_worker() -> None:
|
def _list_worker() -> None:
|
||||||
try:
|
|
||||||
for _ in range(20):
|
|
||||||
_ = loader.list_actors(namespace="local")
|
|
||||||
_ = loader.list_actors(namespace="remote")
|
|
||||||
time.sleep(0.01)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
context._list_errors = getattr(context, "_list_errors", []) # type: ignore[attr-defined]
|
|
||||||
context._list_errors.append(exc) # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
errors_list: list[Exception] = []
|
|
||||||
|
|
||||||
def _error_collector() -> None:
|
|
||||||
try:
|
try:
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
loader.list_actors(namespace="local")
|
loader.list_actors(namespace="local")
|
||||||
|
loader.list_actors(namespace="remote")
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc:
|
||||||
errors_list.append(exc)
|
errors = getattr(context, "_list_errors", []) # type: ignore[attr-defined]
|
||||||
|
errors.append(exc)
|
||||||
|
context._list_errors = errors # type: ignore[attr-defined]
|
||||||
|
|
||||||
workers = []
|
workers: list[threading.Thread] = []
|
||||||
start_thread = context._worker_thread if hasattr(context, "_worker_thread") else None # type: ignore[attr-defined]
|
start_thread = getattr(context, "_worker_thread", None) # type: ignore[attr-defined]
|
||||||
|
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
t = threading.Thread(target=_list_worker, daemon=True)
|
t = threading.Thread(target=_list_worker, daemon=True)
|
||||||
@@ -152,12 +143,13 @@ def step_when_parallel_list(context: Any) -> None:
|
|||||||
|
|
||||||
for t in workers:
|
for t in workers:
|
||||||
t.join(timeout=10)
|
t.join(timeout=10)
|
||||||
|
if start_thread is not None:
|
||||||
|
start_thread.join(timeout=10)
|
||||||
|
|
||||||
# Check both error collectors
|
collected_errors: list[Exception] = [
|
||||||
collected_errors = [*getattr(context, "_list_errors", [])] + errors_list # type: ignore[attr-defined]
|
*getattr(context, "_list_errors", []), # type: ignore[attr-defined]
|
||||||
if hasattr(context, "_concurrent_errors"): # type: ignore[attr-defined]
|
*getattr(context, "_concurrent_errors", []), # type: ignore[attr-defined]
|
||||||
collected_errors.extend(context._concurrent_errors) # type: ignore[attr-defined]
|
]
|
||||||
|
|
||||||
context._final_concurrent_errors = collected_errors # type: ignore[attr-defined]
|
context._final_concurrent_errors = collected_errors # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
@@ -167,7 +159,9 @@ def step_when_parallel_list(context: Any) -> None:
|
|||||||
@then('filtering by namespace "local" should return only local actors')
|
@then('filtering by namespace "local" should return only local actors')
|
||||||
def step_then_local_only(context: Any) -> None:
|
def step_then_local_only(context: Any) -> None:
|
||||||
result = context.actor_loader.list_actors(namespace="local") # type: ignore[attr-defined]
|
result = context.actor_loader.list_actors(namespace="local") # type: ignore[attr-defined]
|
||||||
assert len(result) == 2, f"Expected 2 local actors, got {len(result)}: {[a.name for a in result]}"
|
assert len(result) == 2, (
|
||||||
|
f"Expected 2 local actors, got {len(result)}: {[a.name for a in result]}"
|
||||||
|
)
|
||||||
names = {a.name for a in result}
|
names = {a.name for a in result}
|
||||||
assert names == {"local/alpha", "local/beta"}
|
assert names == {"local/alpha", "local/beta"}
|
||||||
|
|
||||||
@@ -175,12 +169,14 @@ def step_then_local_only(context: Any) -> None:
|
|||||||
@then('filtering by namespace "remote" should return only remote actors')
|
@then('filtering by namespace "remote" should return only remote actors')
|
||||||
def step_then_remote_only(context: Any) -> None:
|
def step_then_remote_only(context: Any) -> None:
|
||||||
result = context.actor_loader.list_actors(namespace="remote") # type: ignore[attr-defined]
|
result = context.actor_loader.list_actors(namespace="remote") # type: ignore[attr-defined]
|
||||||
assert len(result) == 2, f"Expected 2 remote actors, got {len(result)}: {[a.name for a in result]}"
|
assert len(result) == 2, (
|
||||||
|
f"Expected 2 remote actors, got {len(result)}: {[a.name for a in result]}"
|
||||||
|
)
|
||||||
names = {a.name for a in result}
|
names = {a.name for a in result}
|
||||||
assert names == {"remote/gamma", "remote/delta"}
|
assert names == {"remote/gamma", "remote/delta"}
|
||||||
|
|
||||||
|
|
||||||
@then('calling ``list_actors()`` without a filter should return every actor')
|
@then("calling ``list_actors()`` without a filter should return every actor")
|
||||||
def step_then_all_actors(context: Any) -> None:
|
def step_then_all_actors(context: Any) -> None:
|
||||||
result = context.actor_loader.list_actors() # type: ignore[attr-defined]
|
result = context.actor_loader.list_actors() # type: ignore[attr-defined]
|
||||||
assert len(result) == 4, f"Expected 4 total actors, got {len(result)}"
|
assert len(result) == 4, f"Expected 4 total actors, got {len(result)}"
|
||||||
@@ -195,9 +191,6 @@ def step_then_no_errors(context: Any) -> None:
|
|||||||
@then("every returned result should be internally consistent")
|
@then("every returned result should be internally consistent")
|
||||||
def step_then_consistent(context: Any) -> None:
|
def step_then_consistent(context: Any) -> None:
|
||||||
"""Every thread-safe list_actors call returns a stable snapshot."""
|
"""Every thread-safe list_actors call returns a stable snapshot."""
|
||||||
# If we reach here without an assertion error from "no exceptions",
|
|
||||||
# consistency is implied. Verify the namespace filter returned valid data
|
|
||||||
# by checking that all results share the expected structure.
|
|
||||||
loader: ActorLoader = context.actor_loader # type: ignore[attr-defined]
|
loader: ActorLoader = context.actor_loader # type: ignore[attr-defined]
|
||||||
result = loader.list_actors(namespace="local")
|
result = loader.list_actors(namespace="local")
|
||||||
for actor in result:
|
for actor in result:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@tdd_issue @issue_8660
|
@tdd_issue @tdd_issue_8588
|
||||||
Feature: TDD Issue #8660 — ActorLoader.list_actors namespace filter inside lock
|
Feature: TDD Issue #8660 — ActorLoader.list_actors namespace filter inside lock
|
||||||
|
|
||||||
As a developer
|
As a developer
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from cleveragents.actor.loader import ActorLoader
|
from cleveragents.actor.loader import ActorLoader
|
||||||
|
|
||||||
|
|
||||||
def _make_actor_yaml(name: str, provider: str = "openai", model: str = "gpt-4") -> str:
|
def _make_actor_yaml(name: str, provider: str = "openai", model: str = "gpt-4") -> str:
|
||||||
return f"""name: {name}
|
return f"""name: {name}
|
||||||
type: llm
|
type: llm
|
||||||
|
description: Test actor {name}
|
||||||
provider: {provider}
|
provider: {provider}
|
||||||
model: {model}
|
model: {model}
|
||||||
tools: []
|
tools: []
|
||||||
@@ -60,7 +59,9 @@ class TestListActorsThreadSafety:
|
|||||||
try:
|
try:
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
extra_file = root / f"extra_{i}.yaml"
|
extra_file = root / f"extra_{i}.yaml"
|
||||||
extra_file.write_text(_make_actor_yaml(f"conc/ns{i % 2}/thread_{i}"))
|
extra_file.write_text(
|
||||||
|
_make_actor_yaml(f"conc/thread_{i}")
|
||||||
|
)
|
||||||
loader.discover()
|
loader.discover()
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
if extra_file.exists():
|
if extra_file.exists():
|
||||||
@@ -115,9 +116,9 @@ class TestListActorsThreadSafety:
|
|||||||
def _list_worker() -> None:
|
def _list_worker() -> None:
|
||||||
try:
|
try:
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
all_actors = loader.list_actors()
|
loader.list_actors()
|
||||||
local = loader.list_actors(namespace="local")
|
loader.list_actors(namespace="local")
|
||||||
remote = loader.list_actors(namespace="remote")
|
loader.list_actors(namespace="remote")
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(exc)
|
errors.append(exc)
|
||||||
@@ -141,7 +142,9 @@ class TestListActorsThreadSafety:
|
|||||||
def _add_actor() -> None:
|
def _add_actor() -> None:
|
||||||
try:
|
try:
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
(root / f"new_{i}.yaml").write_text(_make_actor_yaml(f"conc/new_{i}"))
|
(root / f"new_{i}.yaml").write_text(
|
||||||
|
_make_actor_yaml(f"conc/new_{i}")
|
||||||
|
)
|
||||||
loader.discover()
|
loader.discover()
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
if (root / f"new_{i}.yaml").exists():
|
if (root / f"new_{i}.yaml").exists():
|
||||||
@@ -172,4 +175,5 @@ class TestListActorsThreadSafety:
|
|||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
Reference in New Issue
Block a user