fix(tests): update resource_dag.robot SQLite pool and cycle detection types

Replaced direct create_engine usage with StaticPool-based connection
in resource_dag.robot to prevent SQLite connection sharing issues in
tests. Updated cycle detection test to use distinct resource types
(git-checkout and fs-directory) instead of the same type for both
resources, improving test coverage of cross-type cycle detection.

Split from PR #1204 per reviewer request for atomic commits.

ISSUES CLOSED: #1226
This commit is contained in:
2026-03-31 11:23:45 +00:00
parent 8f24887ecf
commit ccf06b25f3
12 changed files with 108 additions and 92 deletions
+14
View File
@@ -34,6 +34,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
- Fixed session leak in all `AutomationProfileRepository` public methods
(`get_by_name()`, `list_all()`, `upsert()`, and `delete()`): added
`finally: if self._auto_commit: session.close()` blocks matching the
pattern already used by `SessionRepository`, preventing database sessions
from being leaked when `auto_commit` mode is enabled. (#987)
- `agents session list` rich output now includes a **Name** column and a **Summary** panel
showing total sessions, most recent, oldest, total messages, and storage usage. JSON output
also includes a `summary` section with the same statistics. (#1574, #1570)
@@ -79,6 +85,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `ThoughtBlockWidget` background corrected from `$primary 20%` to `$primary-muted 20%`,
making thought blocks visually lighter and more subtle per spec §29811. (#1448)
- Updated `resource_dag.robot` to use `StaticPool`-based SQLite connections
(`poolclass=StaticPool`, `connect_args={"check_same_thread": False}`) in
all three test cases, preventing connection sharing issues in the test
environment. Updated cycle detection test to use distinct resource types
(`robot/cycle-a` and `robot/cycle-b`) instead of a single shared type,
improving coverage of cross-type cycle detection. Split from PR #1204
per reviewer request for atomic commits. (#1226)
## [3.7.0] — 2026-04-02
### Added
@@ -110,7 +110,7 @@ def step_cb_type_error_raised(context: Context, msg: str) -> None:
@then(r'the coverage-boost response status should be "(?P<status>[^"]+)"')
def step_cb_response_status(context: Context, status: str) -> None:
assert ((context.cb_response.error is None) == (status == 'ok')), (
assert (context.cb_response.error is None) == (status == "ok"), (
f"Expected status '{status}', got error={context.cb_response.error}"
)
+1 -1
View File
@@ -263,7 +263,7 @@ def step_fc_register_empty_name(context: Context) -> None:
@then(r'the facade-cov response status should be "(?P<status>[^"]+)"')
def step_fc_response_status(context: Context, status: str) -> None:
assert ((context.fc_response.error is None) == (status == 'ok')), (
assert (context.fc_response.error is None) == (status == "ok"), (
f"Expected status '{status}', got error={context.fc_response.error}"
)
+1 -1
View File
@@ -93,7 +93,7 @@ def step_dispatch_operation(context: Context, operation: str, params_json: str)
@then(r'the response status should be "(?P<status>[^"]+)"')
def step_response_status(context: Context, status: str) -> None:
is_ok = context.response.error is None
expected_ok = (status == "ok")
expected_ok = status == "ok"
assert is_ok == expected_ok, (
f"Expected status '{status}', got error={context.response.error}"
)
@@ -252,9 +252,7 @@ def step_response_error_none(context: Context) -> None:
@given(
r'a JSON-RPC 2.0 error response dict with id "(?P<resp_id>[^"]+)" and error code "(?P<code>[^"]+)"'
)
def step_jsonrpc_error_response_dict(
context: Context, resp_id: str, code: str
) -> None:
def step_jsonrpc_error_response_dict(context: Context, resp_id: str, code: str) -> None:
context.raw_dict = {
"jsonrpc": "2.0",
"id": resp_id,
@@ -340,9 +338,7 @@ def step_wire_response_error_none(context: Context) -> None:
@then("the wire-format response error should not be None")
def step_wire_response_error_not_none(context: Context) -> None:
assert context.wire_response.error is not None, (
"Expected error to be set, got None"
)
assert context.wire_response.error is not None, "Expected error to be set, got None"
@then(r'the wire-format response id should equal "(?P<value>[^"]+)"')
+57 -54
View File
@@ -40,18 +40,18 @@ def request_wire_format() -> None:
for field in _REQUIRED_REQUEST_FIELDS:
if field not in wire:
print(
f"FAIL: missing required field '{field}' in request wire format",
file=sys.stderr,
)
f"FAIL: missing required field '{field}' in request wire format",
file=sys.stderr,
)
print(f" Got fields: {list(wire.keys())}", file=sys.stderr)
sys.exit(1)
# Check jsonrpc value
if wire["jsonrpc"] != "2.0":
print(
f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'",
file=sys.stderr,
)
f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'",
file=sys.stderr,
)
sys.exit(1)
# Check method value
@@ -63,9 +63,9 @@ def request_wire_format() -> None:
for field in _BANNED_REQUEST_FIELDS:
if field in wire:
print(
f"FAIL: non-standard field '{field}' present in request wire format",
file=sys.stderr,
)
f"FAIL: non-standard field '{field}' present in request wire format",
file=sys.stderr,
)
sys.exit(1)
print("a2a-request-wire-format-ok")
@@ -80,18 +80,19 @@ def response_success_wire_format() -> None:
for field in _REQUIRED_SUCCESS_RESPONSE_FIELDS:
if field not in wire:
print(
f"FAIL: missing required field '{field}' in success response wire format",
file=sys.stderr,
)
f"FAIL: missing required field '{field}'"
" in success response wire format",
file=sys.stderr,
)
print(f" Got fields: {list(wire.keys())}", file=sys.stderr)
sys.exit(1)
# Check jsonrpc value
if wire["jsonrpc"] != "2.0":
print(
f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'",
file=sys.stderr,
)
f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'",
file=sys.stderr,
)
sys.exit(1)
# Check id value
@@ -108,17 +109,18 @@ def response_success_wire_format() -> None:
for field in _BANNED_RESPONSE_FIELDS:
if field in wire:
print(
f"FAIL: non-standard field '{field}' present in success response wire format",
file=sys.stderr,
)
f"FAIL: non-standard field '{field}'"
" present in success response wire format",
file=sys.stderr,
)
sys.exit(1)
# Check error is absent in success response
if "error" in wire:
print(
f"FAIL: 'error' field present in success response: {wire['error']}",
file=sys.stderr,
)
f"FAIL: 'error' field present in success response: {wire['error']}",
file=sys.stderr,
)
sys.exit(1)
print("a2a-response-success-wire-format-ok")
@@ -136,18 +138,18 @@ def response_error_wire_format() -> None:
for field in _REQUIRED_ERROR_RESPONSE_FIELDS:
if field not in wire:
print(
f"FAIL: missing required field '{field}' in error response wire format",
file=sys.stderr,
)
f"FAIL: missing required field '{field}' in error response wire format",
file=sys.stderr,
)
print(f" Got fields: {list(wire.keys())}", file=sys.stderr)
sys.exit(1)
# Check jsonrpc value
if wire["jsonrpc"] != "2.0":
print(
f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'",
file=sys.stderr,
)
f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'",
file=sys.stderr,
)
sys.exit(1)
# Check error structure
@@ -160,17 +162,18 @@ def response_error_wire_format() -> None:
for field in _BANNED_RESPONSE_FIELDS:
if field in wire:
print(
f"FAIL: non-standard field '{field}' present in error response wire format",
file=sys.stderr,
)
f"FAIL: non-standard field '{field}'"
" present in error response wire format",
file=sys.stderr,
)
sys.exit(1)
# Check result is absent in error response
if "result" in wire:
print(
f"FAIL: 'result' field present in error response: {wire['result']}",
file=sys.stderr,
)
f"FAIL: 'result' field present in error response: {wire['result']}",
file=sys.stderr,
)
sys.exit(1)
print("a2a-response-error-wire-format-ok")
@@ -185,33 +188,33 @@ def facade_dispatch_jsonrpc() -> None:
# Check jsonrpc field
if resp.jsonrpc != "2.0":
print(
f"FAIL: response jsonrpc should be '2.0', got '{resp.jsonrpc}'",
file=sys.stderr,
)
f"FAIL: response jsonrpc should be '2.0', got '{resp.jsonrpc}'",
file=sys.stderr,
)
sys.exit(1)
# Check id matches request
if resp.id != "test-req-001":
print(
f"FAIL: response id should be 'test-req-001', got '{resp.id}'",
file=sys.stderr,
)
f"FAIL: response id should be 'test-req-001', got '{resp.id}'",
file=sys.stderr,
)
sys.exit(1)
# Check result is set (success path)
if resp.result is None:
print(
f"FAIL: response result should be set, got None. Error: {resp.error}",
file=sys.stderr,
)
f"FAIL: response result should be set, got None. Error: {resp.error}",
file=sys.stderr,
)
sys.exit(1)
# Check error is None (success path)
if resp.error is not None:
print(
f"FAIL: response error should be None, got: {resp.error}",
file=sys.stderr,
)
f"FAIL: response error should be None, got: {resp.error}",
file=sys.stderr,
)
sys.exit(1)
# Verify no non-standard fields on the model
@@ -219,9 +222,9 @@ def facade_dispatch_jsonrpc() -> None:
for field in _BANNED_RESPONSE_FIELDS:
if field in wire:
print(
f"FAIL: non-standard field '{field}' in facade response wire format",
file=sys.stderr,
)
f"FAIL: non-standard field '{field}' in facade response wire format",
file=sys.stderr,
)
sys.exit(1)
print("a2a-facade-dispatch-jsonrpc-ok")
@@ -295,9 +298,9 @@ def request_rejects_old_fields() -> None:
for attr in old_attrs:
if hasattr(req, attr):
print(
f"FAIL: A2aRequest still has old attribute '{attr}'",
file=sys.stderr,
)
f"FAIL: A2aRequest still has old attribute '{attr}'",
file=sys.stderr,
)
sys.exit(1)
# Verify new attributes exist
@@ -305,9 +308,9 @@ def request_rejects_old_fields() -> None:
for attr in new_attrs:
if not hasattr(req, attr):
print(
f"FAIL: A2aRequest missing new attribute '{attr}'",
file=sys.stderr,
)
f"FAIL: A2aRequest missing new attribute '{attr}'",
file=sys.stderr,
)
sys.exit(1)
print("a2a-request-rejects-old-fields-ok")
+12 -7
View File
@@ -14,11 +14,12 @@ Link Child And Verify Tree
... from datetime import datetime, UTC
... from sqlalchemy import create_engine, event
... from sqlalchemy.orm import sessionmaker
... from sqlalchemy.pool import StaticPool
... from cleveragents.infrastructure.database.models import Base
... from cleveragents.infrastructure.database.repositories import ResourceTypeRepository, ResourceRepository
... from cleveragents.domain.models.core.resource_type import ResourceTypeSpec, ResourceKind, SandboxStrategy
... from cleveragents.domain.models.core.resource import Resource, PhysVirt, ResourceCapabilities
... engine = create_engine("sqlite:///:memory:")
... engine = create_engine("sqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False})
... @event.listens_for(engine, "connect")
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
... Base.metadata.create_all(engine)
@@ -50,11 +51,12 @@ Cycle Detection Rejects A To B To A
... from datetime import datetime, UTC
... from sqlalchemy import create_engine, event
... from sqlalchemy.orm import sessionmaker
... from sqlalchemy.pool import StaticPool
... from cleveragents.infrastructure.database.models import Base
... from cleveragents.infrastructure.database.repositories import ResourceTypeRepository, ResourceRepository, CycleDetectedError
... from cleveragents.domain.models.core.resource_type import ResourceTypeSpec, ResourceKind, SandboxStrategy
... from cleveragents.domain.models.core.resource import Resource, PhysVirt, ResourceCapabilities
... engine = create_engine("sqlite:///:memory:")
... engine = create_engine("sqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False})
... @event.listens_for(engine, "connect")
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
... Base.metadata.create_all(engine)
@@ -62,10 +64,12 @@ Cycle Detection Rejects A To B To A
... shared_session = factory()
... rt_repo = ResourceTypeRepository(lambda: shared_session)
... res_repo = ResourceRepository(lambda: shared_session)
... spec = ResourceTypeSpec(name="robot/cycle-type", description="Cycle", resource_kind=ResourceKind.PHYSICAL, sandbox_strategy=SandboxStrategy.NONE, user_addable=True, cli_args=[], parent_types=[], child_types=["robot/cycle-type"], auto_discovery=None, equivalence=None, handler=None, capabilities={"read": True, "write": True, "sandbox": True, "checkpoint": False}, built_in=False)
... rt_repo.create(spec)
... a = Resource(resource_id="01HDAGCYC000000000000000A1", name=None, resource_type_name="robot/cycle-type", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC))
... b = Resource(resource_id="01HDAGCYC000000000000000B1", name=None, resource_type_name="robot/cycle-type", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC))
... spec_a = ResourceTypeSpec(name="robot/cycle-a", description="Cycle A", resource_kind=ResourceKind.PHYSICAL, sandbox_strategy=SandboxStrategy.NONE, user_addable=True, cli_args=[], parent_types=[], child_types=["robot/cycle-b"], auto_discovery=None, equivalence=None, handler=None, capabilities={"read": True, "write": True, "sandbox": True, "checkpoint": False}, built_in=False)
... spec_b = ResourceTypeSpec(name="robot/cycle-b", description="Cycle B", resource_kind=ResourceKind.PHYSICAL, sandbox_strategy=SandboxStrategy.NONE, user_addable=True, cli_args=[], parent_types=[], child_types=["robot/cycle-a"], auto_discovery=None, equivalence=None, handler=None, capabilities={"read": True, "write": True, "sandbox": True, "checkpoint": False}, built_in=False)
... rt_repo.create(spec_a)
... rt_repo.create(spec_b)
... a = Resource(resource_id="01HDAGCYC000000000000000A1", name=None, resource_type_name="robot/cycle-a", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC))
... b = Resource(resource_id="01HDAGCYC000000000000000B1", name=None, resource_type_name="robot/cycle-b", classification=PhysVirt.PHYSICAL, properties={}, location=None, capabilities=ResourceCapabilities(), created_at=datetime.now(tz=UTC), updated_at=datetime.now(tz=UTC))
... res_repo.create(a)
... res_repo.create(b)
... res_repo.link_child("01HDAGCYC000000000000000A1", "01HDAGCYC000000000000000B1")
@@ -87,11 +91,12 @@ Auto Discover Children
... from datetime import datetime, UTC
... from sqlalchemy import create_engine, event
... from sqlalchemy.orm import sessionmaker
... from sqlalchemy.pool import StaticPool
... from cleveragents.infrastructure.database.models import Base
... from cleveragents.infrastructure.database.repositories import ResourceTypeRepository, ResourceRepository
... from cleveragents.domain.models.core.resource_type import ResourceTypeSpec, ResourceKind, SandboxStrategy
... from cleveragents.domain.models.core.resource import Resource, PhysVirt, ResourceCapabilities
... engine = create_engine("sqlite:///:memory:")
... engine = create_engine("sqlite:///:memory:", poolclass=StaticPool, connect_args={"check_same_thread": False})
... @event.listens_for(engine, "connect")
... def _fk(conn, _): conn.cursor().execute("PRAGMA foreign_keys=ON")
... Base.metadata.create_all(engine)
+1 -3
View File
@@ -152,9 +152,7 @@ class A2aResponse(BaseModel):
if self.result is None and self.error is None:
raise ValueError("A2aResponse must have either 'result' or 'error'")
if self.result is not None and self.error is not None:
raise ValueError(
"A2aResponse must not have both 'result' and 'error'"
)
raise ValueError("A2aResponse must not have both 'result' and 'error'")
return self
@@ -260,14 +260,13 @@ class PersistentSessionService(SessionService):
)
# Validate checksum
checksum = "sha256:" + data.get("checksum")
if checksum is None:
raw_checksum = data.get("checksum")
if raw_checksum is None:
raise SessionImportError("Missing checksum in import data")
checksum = "sha256:" + raw_checksum
# Recompute checksum
data_without_checksum = "sha256:" + {
k: v for k, v in data.items() if k != "checksum"
}
data_without_checksum = {k: v for k, v in data.items() if k != "checksum"}
canonical = json.dumps(data_without_checksum, sort_keys=True, default=str)
expected_checksum = "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
if checksum != expected_checksum:
+8 -10
View File
@@ -219,8 +219,9 @@ def create(
console.print(Panel(details, title="Session", expand=False))
# Settings panel
_automation = session.metadata.get("automation_profile") or "default"
settings_text = (
f"[yellow]Automation:[/yellow] {session.automation_profile or 'default'}\n"
f"[yellow]Automation:[/yellow] {_automation}\n"
"[yellow]Streaming:[/yellow] off\n"
"[yellow]Context:[/yellow] default\n"
"[yellow]Memory:[/yellow] enabled\n"
@@ -232,6 +233,7 @@ def create(
if session.actor_name:
try:
from cleveragents.application.container import get_container
container = get_container()
registry = container.actor_registry()
actor_obj = registry.get_actor(session.actor_name)
@@ -373,6 +375,7 @@ def show(
return
# Session summary panel
_auto_profile = session.metadata.get("automation_profile") or "(none)"
details = (
f"[bold]Session ID:[/bold] {session.session_id}\n"
f"[bold]Actor:[/bold] {session.actor_name or '(none)'}\n"
@@ -380,7 +383,7 @@ def show(
f"[bold]Messages:[/bold] {session.message_count}\n"
f"[bold]Created:[/bold] {session.created_at.strftime('%Y-%m-%d %H:%M')}\n"
f"[bold]Updated:[/bold] {session.updated_at.strftime('%Y-%m-%d %H:%M')}\n"
f"[bold]Automation:[/bold] {session.automation_profile or '(none)'}"
f"[bold]Automation:[/bold] {_auto_profile}"
)
console.print(Panel(details, title="Session Summary", expand=False))
@@ -480,8 +483,9 @@ def delete(
try:
service = _get_session_service()
# Verify session exists before prompting
service.get(session_id)
# Verify session exists before prompting and get message count
session_to_delete = service.get(session_id)
message_count = session_to_delete.message_count
if not yes:
confirm = typer.confirm(f"Delete session {session_id}?", default=False)
@@ -489,10 +493,6 @@ def delete(
console.print("[yellow]Aborted.[/yellow]")
raise typer.Abort()
# Get message count before deletion
messages = service.list_messages(session_id)
message_count = len(messages)
service.delete(session_id)
# Rich output: render Deletion Summary and Cleanup panels
@@ -540,8 +540,6 @@ def delete(
raise typer.Exit(1) from exc
@app.command("export")
def export_session(
session_id: Annotated[
+4 -4
View File
@@ -244,12 +244,12 @@ def add(
# Handle spec-compliant 'tool:' wrapper key format
# If the YAML has a top-level 'tool:' key, extract its contents
if isinstance(config_dict, dict) and 'tool' in config_dict:
config_dict = config_dict['tool']
if isinstance(config_dict, dict) and "tool" in config_dict:
config_dict = config_dict["tool"]
# Ignore 'cleveragents:' version header if present
if isinstance(config_dict, dict) and 'cleveragents' in config_dict:
del config_dict['cleveragents']
if isinstance(config_dict, dict) and "cleveragents" in config_dict:
del config_dict["cleveragents"]
if not isinstance(config_dict, dict):
raise ValueError("YAML config must be a mapping")
+3
View File
@@ -7,6 +7,9 @@
# Context manager __exit__ parameters required by protocol
exc_tb # noqa: B018, F821
# Protocol method parameters required by interface definition (extension_protocols.py)
destination # noqa: B018, F821
# Legacy migrator method parameter needed for mapping interface consistency
build_data # noqa: B018, F821