fix(cli): handle missing database in session list command #723

Merged
brent.edwards merged 3 commits from bugfix/m3-session-list-error into master 2026-03-12 21:30:33 +00:00
20 changed files with 427 additions and 146 deletions
+13
View File
@@ -2,6 +2,19 @@
## Unreleased
- Fixed `agents session list`, `agents session create`, and other session
subcommands raising `AttributeError: 'DynamicContainer' object has no
attribute 'db'` after `agents init`. Root cause: `_get_session_service()`
called `container.db()` but no `db` provider existed. Added a
`session_service` DI provider in `container.py` that builds the engine,
sessionmaker, and auto-committing repositories. Rewrote
`_get_session_service()` to resolve via the container with module-level
caching. Added `auto_commit` parameter to `SessionRepository` and
`SessionMessageRepository` to prevent resource leaks in CLI context while
preserving Unit-of-Work semantics. Unified error handling across all 7
session subcommands. Includes Behave BDD regression scenarios, Robot
Framework integration smoke tests, and structlog isolation for parallel
test execution. (#554, #570, #680)
- Added dedicated E2E test infrastructure: new `nox -s e2e_tests` session
running Robot Framework with `--include E2E` tag filter against `robot/e2e/`
directory, dedicated CI job with real LLM API key secrets, graceful skip
+7 -9
View File
@@ -1,8 +1,6 @@
# TDD tests for bug #570 — expected to fail until the DI container fix lands.
# The @tdd_expected_fail tag causes the test framework to invert pass/fail so
# these scenarios pass CI while the bug is unfixed. The bug-fix developer
# removes @tdd_expected_fail (keeping @tdd_bug and @tdd_bug_570) once the fix
# is applied.
# Regression tests for bug #570 — verifies that the DI container fix for
# session create command works correctly. The @tdd_expected_fail tags have
# been removed now that the fix has landed.
Feature: Session create command resolves DI container wiring
As a developer using the agents CLI
I want "agents session create" to work after a fresh init
@@ -11,27 +9,27 @@ Feature: Session create command resolves DI container wiring
Background:
Given a session-create-error CLI runner using the real DI path
@tdd_bug @tdd_bug_570 @tdd_expected_fail
@tdd_bug @tdd_bug_570
Scenario: Session create produces a new session
When I invoke session-create-error create with no arguments
Then the session-create-error command should exit successfully
And the session-create-error output should contain "session_id:"
@tdd_bug @tdd_bug_570 @tdd_expected_fail
@tdd_bug @tdd_bug_570
Scenario: Created session persists and can be retrieved
When I invoke session-create-error create with no arguments
Then the session-create-error command should exit successfully
When I invoke session-create-error list to verify persistence
Then the session-create-error list should show at least one session
@tdd_bug @tdd_bug_570 @tdd_expected_fail
@tdd_bug @tdd_bug_570
Scenario: Session create with custom actor succeeds
When I invoke session-create-error create with actor "openai/gpt-4"
Then the session-create-error command should exit successfully
And the session-create-error output should contain "openai/gpt-4"
And the session-create-error output should contain "session_id:"
@tdd_bug @tdd_bug_570 @tdd_expected_fail
@tdd_bug @tdd_bug_570
Scenario: Session create with arbitrary actor name succeeds
When I invoke session-create-error create with actor "nonexistent/bogus-actor-999"
Then the session-create-error command should exit successfully
+15 -15
View File
@@ -1,6 +1,6 @@
# TDD tests for bug #554 — expected to fail until the DI container fix lands.
# Once the fix is applied, remove the @tdd_expected_fail tags and verify all
# scenarios pass.
# Regression tests for bug #554 — verifies that the DI container fix for
# session list command works correctly. The @tdd_expected_fail tags have
# been removed now that the fix has landed.
Feature: Session list command handles missing database gracefully
As a developer using the agents CLI
I want "agents session list" to work after a fresh init
@@ -9,28 +9,28 @@ Feature: Session list command handles missing database gracefully
Background:
Given a session-list-error CLI runner using the real DI path
@tdd_bug @tdd_bug_554 @tdd_expected_fail
@tdd_bug @tdd_bug_554
Scenario: Session list returns empty list when no sessions exist
When I invoke session-list-error list with default format
Then the session-list-error command should exit successfully
And the session-list-error output should contain "No sessions found"
And the session-list-error output should not contain "AttributeError"
@tdd_bug @tdd_bug_554 @tdd_expected_fail
@tdd_bug @tdd_bug_554
Scenario: Session list after init does not raise DI error
When I invoke session-list-error list with default format
Then the session-list-error command should exit successfully
And the session-list-error output should not contain "AttributeError"
And the session-list-error output should not contain "INTERNAL"
@tdd_bug @tdd_bug_554 @tdd_expected_fail
@tdd_bug @tdd_bug_554
Scenario: Session list returns sessions after creation via service
Given a session-list-error service with a pre-populated session
When I invoke session-list-error list with default format
Then the session-list-error command should exit successfully
And the session-list-error output should contain "Sessions ("
@tdd_bug @tdd_bug_554 @tdd_expected_fail
@tdd_bug @tdd_bug_554
Scenario: Session list works with rich output format
Given a session-list-error service with a pre-populated session
When I invoke session-list-error list with format "rich"
@@ -38,21 +38,21 @@ Feature: Session list command handles missing database gracefully
And the session-list-error output should contain "Sessions ("
And the session-list-error output should not contain "AttributeError"
@tdd_bug @tdd_bug_554 @tdd_expected_fail
@tdd_bug @tdd_bug_554
Scenario: Session list works with JSON output format
Given a session-list-error service with a pre-populated session
When I invoke session-list-error list with format "json"
Then the session-list-error command should exit successfully
And the session-list-error output should be valid JSON containing "sessions"
@tdd_bug @tdd_bug_554 @tdd_expected_fail
@tdd_bug @tdd_bug_554
Scenario: Session list works with plain output format
Given a session-list-error service with a pre-populated session
When I invoke session-list-error list with format "plain"
Then the session-list-error command should exit successfully
And the session-list-error output should contain "Sessions ("
And the session-list-error output should contain "total:"
@tdd_bug @tdd_bug_554 @tdd_expected_fail
@tdd_bug @tdd_bug_554
Scenario: Session list works with YAML output format
Given a session-list-error service with a pre-populated session
When I invoke session-list-error list with format "yaml"
@@ -63,23 +63,23 @@ Feature: Session list command handles missing database gracefully
# with explicit output formats. The production code currently bypasses
# --format for empty lists, so these document the expected behaviour.
@tdd_bug @tdd_bug_554 @tdd_expected_fail
@tdd_bug @tdd_bug_554
Scenario: Empty session list with JSON format produces valid JSON
When I invoke session-list-error list with format "json"
Then the session-list-error command should exit successfully
And the session-list-error output should be valid JSON containing "sessions"
And the session-list-error output should not contain "AttributeError"
@tdd_bug @tdd_bug_554 @tdd_expected_fail
@tdd_bug @tdd_bug_554
Scenario: Empty session list with YAML format produces valid YAML
When I invoke session-list-error list with format "yaml"
Then the session-list-error command should exit successfully
And the session-list-error output should be valid YAML containing "sessions"
And the session-list-error output should not contain "AttributeError"
@tdd_bug @tdd_bug_554 @tdd_expected_fail
@tdd_bug @tdd_bug_554
Scenario: Empty session list with plain format does not error
When I invoke session-list-error list with format "plain"
Then the session-list-error command should exit successfully
And the session-list-error output should contain "No sessions found"
And the session-list-error output should contain "total: 0"
And the session-list-error output should not contain "AttributeError"
@@ -102,48 +102,24 @@ def step_service_is_none(context):
def step_call_get_session_service(context):
import cleveragents.cli.commands.session as mod
mock_db = MagicMock()
mock_service_instance = MagicMock()
mock_container = MagicMock()
mock_container.db.return_value = mock_db
mock_container.session_service.return_value = mock_service_instance
context._mock_persistent_instance = mock_service_instance
mock_session_repo_cls = MagicMock()
mock_message_repo_cls = MagicMock()
mock_persistent_cls = MagicMock()
context._mock_persistent_instance = mock_persistent_cls.return_value
import sys
with patch("cleveragents.cli.commands.session.get_container", create=True):
# We need to mock the imports that happen inside the function.
# The function does:
# from cleveragents.application.container import get_container
# from cleveragents.application.services.session_service import PersistentSessionService
# from cleveragents.infrastructure.database.repositories import SessionRepository, SessionMessageRepository
# We mock these at the module import level inside the function.
mock_container_mod = MagicMock()
mock_container_mod.get_container = MagicMock(return_value=mock_container)
import sys
# Create mock modules
mock_container_mod = MagicMock()
mock_container_mod.get_container = MagicMock(return_value=mock_container)
mock_service_mod = MagicMock()
mock_service_mod.PersistentSessionService = mock_persistent_cls
mock_repo_mod = MagicMock()
mock_repo_mod.SessionRepository = mock_session_repo_cls
mock_repo_mod.SessionMessageRepository = mock_message_repo_cls
with patch.dict(
sys.modules,
{
"cleveragents.application.container": mock_container_mod,
"cleveragents.application.services.session_service": mock_service_mod,
"cleveragents.infrastructure.database.repositories": mock_repo_mod,
},
):
# Force re-import by deleting cached names if any
# The function uses local imports so they run every time
result = mod._get_session_service()
context._get_service_result = result
with patch.dict(
sys.modules,
{
"cleveragents.application.container": mock_container_mod,
},
):
result = mod._get_session_service()
context._get_service_result = result
@then("session cli branch a PersistentSessionService is returned")
+47 -9
View File
@@ -24,6 +24,7 @@ wiring that triggers the bug, so we must bypass the cache to exercise it.
from __future__ import annotations
import json
import logging
import os
import shutil
import tempfile
@@ -32,7 +33,7 @@ import yaml
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.orm import sessionmaker
from typer.testing import CliRunner
from cleveragents.application.container import reset_container
@@ -41,6 +42,7 @@ from cleveragents.application.services.session_service import (
)
from cleveragents.cli.commands import session as session_mod
from cleveragents.cli.commands.session import app as session_app
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
SessionMessageRepository,
@@ -55,6 +57,37 @@ runner = CliRunner()
# ---------------------------------------------------------------------------
def _suppress_structlog_stdout() -> tuple[int, bool]:
"""Prevent structlog debug lines from contaminating CLI stdout.
See ``tdd_session_shared_steps._suppress_structlog_stdout`` for the
full rationale.
"""
import structlog
root = logging.getLogger()
prev_level = root.level
root.setLevel(logging.WARNING)
prev_config = structlog.get_config()
prev_cache: bool = prev_config.get("cache_logger_on_first_use", True) # type: ignore[assignment]
structlog.configure(
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=False,
)
return prev_level, prev_cache
def _restore_structlog(prev_level: int, prev_cache: bool) -> None:
"""Undo the suppression applied by :func:`_suppress_structlog_stdout`."""
import structlog
logging.getLogger().setLevel(prev_level)
structlog.configure(cache_logger_on_first_use=prev_cache)
def _setup_real_di_path(context: Context) -> None:
"""Prepare a temp dir with a fresh SQLite DB and override container."""
# Store original _service so cleanup can restore it.
@@ -63,6 +96,11 @@ def _setup_real_di_path(context: Context) -> None:
# Reset any stale DI container singleton before configuring the env var
# so that cached providers don't carry over from a prior test suite (F17).
reset_container()
Settings._instance = None # type: ignore[attr-defined]
# Suppress structlog debug output so CliRunner captures clean output.
prev_level, prev_cache = _suppress_structlog_stdout()
context.sle_log_prev = (prev_level, prev_cache)
context.sle_tmpdir = tempfile.mkdtemp(prefix="session_list_err_554_")
@@ -99,6 +137,11 @@ def _cleanup_sle(context: Context) -> None:
# Reset the DI container singleton to avoid polluting later scenarios.
reset_container()
Settings._instance = None # type: ignore[attr-defined]
# Restore structlog / logging state.
prev_level, prev_cache = context.sle_log_prev
_restore_structlog(prev_level, prev_cache)
shutil.rmtree(context.sle_tmpdir, ignore_errors=True)
@@ -124,16 +167,11 @@ def step_pre_populate_session(context: Context) -> None:
db_url = f"sqlite:///{context.sle_db_path}"
engine = create_engine(db_url, echo=False)
try:
factory = scoped_session(
sessionmaker(bind=engine, expire_on_commit=False),
)
repo = SessionRepository(session_factory=factory)
msg_repo = SessionMessageRepository(session_factory=factory)
factory = sessionmaker(bind=engine, expire_on_commit=False)
repo = SessionRepository(session_factory=factory, auto_commit=True)
msg_repo = SessionMessageRepository(session_factory=factory, auto_commit=True)
svc = PersistentSessionService(repo, msg_repo)
svc.create(actor_name="openai/gpt-4")
# Commit via the scoped session so the data is visible to later queries.
factory().commit()
factory.remove()
finally:
engine.dispose()
@@ -1,8 +1,9 @@
"""Step definitions for TDD Bug #570 — session create DI error.
"""Step definitions for TDD Bug #570 — session create DI regression tests.
These steps exercise the *real* DI path in ``_get_session_service()`` without
mocking, so the ``container.db()`` ``AttributeError`` is triggered. The
``@tdd_expected_fail`` tag on the scenarios inverts the result.
These steps exercise the *real* DI path in ``_get_session_service()``.
Bug #570 has been fixed (same root cause as #554): the DI container
now provides a ``session_service`` provider that builds the service
correctly.
"""
from __future__ import annotations
+4 -6
View File
@@ -1,10 +1,8 @@
"""Step definitions for TDD Bug #554 — session list DI error.
"""Step definitions for TDD Bug #554 — session list DI regression tests.
These steps exercise the *real* DI path in ``_get_session_service()`` without
mocking, so the ``container.db()`` ``AttributeError`` is triggered. The
``@tdd_expected_fail`` tag on the scenarios inverts the result: these tests
**pass** CI while the bug is present and will **fail** once the bug is fixed
(signalling that the tag should be removed).
These steps exercise the *real* DI path in ``_get_session_service()``.
Bug #554 has been fixed: the DI container now provides a
``session_service`` provider that builds the service correctly.
"""
from __future__ import annotations
@@ -15,7 +15,9 @@ Container provides a ``db`` Singleton that auto-creates the database via
from __future__ import annotations
import json
import logging
import os
import shutil
import tempfile
from pathlib import Path
@@ -29,6 +31,37 @@ from cleveragents.cli.commands.session import app as session_app
from cleveragents.config.settings import Settings
def _suppress_structlog_stdout() -> tuple[int, bool]:
"""Prevent structlog debug lines from contaminating CLI stdout.
See ``tdd_session_shared_steps._suppress_structlog_stdout`` for the
full rationale.
"""
import structlog
root = logging.getLogger()
prev_level = root.level
root.setLevel(logging.WARNING)
prev_config = structlog.get_config()
prev_cache: bool = prev_config.get("cache_logger_on_first_use", True) # type: ignore[assignment]
structlog.configure(
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=False,
)
return prev_level, prev_cache
def _restore_structlog(prev_level: int, prev_cache: bool) -> None:
"""Undo the suppression applied by :func:`_suppress_structlog_stdout`."""
import structlog
logging.getLogger().setLevel(prev_level)
structlog.configure(cache_logger_on_first_use=prev_cache)
@given("a CLI runner with no database file present")
def step_runner_no_db(context: Context) -> None:
"""Set up a CLI runner pointing at a database path that does not exist.
@@ -38,6 +71,14 @@ def step_runner_no_db(context: Context) -> None:
auto-create the database or fail with an appropriate error.
"""
context.runner = CliRunner()
# Reset singletons before setup so stale state does not leak in.
reset_container()
Settings._instance = None # type: ignore[attr-defined]
# Suppress structlog debug output so CliRunner captures clean output.
prev_level, prev_cache = _suppress_structlog_stdout()
context.missing_db_tmpdir = tempfile.mkdtemp(prefix="tdd_missing_db_680_")
# Point at a file that does NOT exist — this is the key difference from
# the shared step ``a CLI runner using the real session DI path`` which
@@ -53,13 +94,12 @@ def step_runner_no_db(context: Context) -> None:
session_mod._service = None
os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)
reset_container()
Settings._instance = None
Settings._instance = None # type: ignore[attr-defined]
# The fix may auto-create the database file, so clean it up.
Path(db_path).unlink(missing_ok=True)
# Remove the temp directory.
import shutil
shutil.rmtree(context.missing_db_tmpdir, ignore_errors=True)
_restore_structlog(prev_level, prev_cache)
context.add_cleanup(_cleanup)
+69 -5
View File
@@ -10,6 +10,7 @@ shared steps live in clearly named reusable modules.
from __future__ import annotations
import json
import logging
import os
import tempfile
from pathlib import Path
@@ -23,25 +24,87 @@ from cleveragents.cli.commands import session as session_mod
from cleveragents.config.settings import Settings
def _suppress_structlog_stdout() -> tuple[int, bool]:
"""Prevent structlog debug lines from contaminating CLI stdout.
When ``behave-parallel`` runs multiple features in one process,
another feature may (re)configure structlog with a ``ConsoleRenderer``
or leave it at the default ``PrintLogger`` — both write to
``sys.stdout``. The ``@database_retry`` decorator logs a *debug*
event on every attempt, and ``CliRunner.invoke()`` captures
``sys.stdout``, so those debug lines end up in the captured output,
breaking JSON/YAML assertions.
Returns the previous root-logger level and ``cache_logger_on_first_use``
flag so that the caller's cleanup function can restore them.
"""
import structlog
root = logging.getLogger()
prev_level = root.level
root.setLevel(logging.WARNING)
# Force structlog to route through stdlib logging (not PrintLogger)
# and disable logger caching so the level change takes effect for
# loggers that were already instantiated.
prev_config = structlog.get_config()
prev_cache: bool = prev_config.get("cache_logger_on_first_use", True) # type: ignore[assignment]
structlog.configure(
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=False,
)
return prev_level, prev_cache
def _restore_structlog(prev_level: int, prev_cache: bool) -> None:
"""Undo the suppression applied by :func:`_suppress_structlog_stdout`."""
import structlog
logging.getLogger().setLevel(prev_level)
structlog.configure(cache_logger_on_first_use=prev_cache)
@given("a CLI runner using the real session DI path")
def step_real_di_runner(context: Context) -> None:
"""Set up a CLI runner that does NOT mock the session service.
By ensuring ``session_mod._service`` is ``None``, the CLI will call
``_get_session_service()`` which hits the real DI container and
triggers the ``container.db()`` bug.
resolves the ``session_service`` provider.
"""
context.runner = CliRunner()
# Ensure we go through the real DI path — no mock service.
session_mod._service = None
# Provide a database URL so the container can be constructed (the bug
# triggers before the URL is actually used).
# Reset singletons before setup so stale state from a parallel
# scenario does not leak in.
reset_container()
Settings._instance = None # type: ignore[attr-defined]
# Suppress structlog debug output so CliRunner captures clean output.
prev_level, prev_cache = _suppress_structlog_stdout()
# Provide a database URL so the container can be constructed.
fd, db_path = tempfile.mkstemp(suffix=".db")
os.close(fd)
context._tdd_db_path = db_path
os.environ["CLEVERAGENTS_DATABASE_URL"] = f"sqlite:///{db_path}"
db_url = f"sqlite:///{db_path}"
os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url
# Create the schema so the database file is ready for use.
from sqlalchemy import create_engine
from cleveragents.infrastructure.database.models import Base
engine = create_engine(db_url, echo=False)
try:
Base.metadata.create_all(engine)
finally:
engine.dispose()
def _cleanup() -> None:
session_mod._service = None
@@ -49,8 +112,9 @@ def step_real_di_runner(context: Context) -> None:
reset_container()
# Reset the Settings singleton so stale database URLs from this
# scenario do not leak into subsequent scenarios.
Settings._instance = None
Settings._instance = None # type: ignore[attr-defined]
Path(db_path).unlink(missing_ok=True)
_restore_structlog(prev_level, prev_cache)
context.add_cleanup(_cleanup)
-3
View File
@@ -9,19 +9,16 @@ Feature: TDD Bug #570 — session create DI container missing db provider
session.py calls `container.db()`, but the Container class has no `db`
provider, causing an AttributeError at runtime.
@tdd_expected_fail
Scenario: Session create command succeeds via DI container
Given a CLI runner using the real session DI path
When I invoke the session create command
Then the session create command should exit successfully
@tdd_expected_fail
Scenario: Session create with actor succeeds via DI container
Given a CLI runner using the real session DI path
When I invoke the session create command with actor "openai/gpt-4"
Then the session create command should exit successfully
@tdd_expected_fail
Scenario: Session create command produces structured output via DI
Given a CLI runner using the real session DI path
When I invoke the session create command with format json
-3
View File
@@ -9,19 +9,16 @@ Feature: TDD Bug #554 — session list DI container missing db provider
`container.db()`, but the Container class has no `db` provider, causing
an AttributeError at runtime.
@tdd_expected_fail
Scenario: Session list command succeeds via DI container
Given a CLI runner using the real session DI path
When I invoke the session list command
Then the session list command should exit successfully
@tdd_expected_fail
Scenario: Session list DI path resolves a SessionService
Given a CLI runner using the real session DI path
When I request the session service from the DI container
Then the session service should be a valid SessionService instance
@tdd_expected_fail
Scenario: Session list command produces structured output via DI
Given a CLI runner using the real session DI path
When I invoke the session list command with format json
@@ -12,21 +12,18 @@ Feature: TDD Bug #680 — session list with missing database
database file itself must be auto-created so the command returns an
empty list rather than crashing.
@tdd_expected_fail
Scenario: Session list with missing database exits successfully
Given a CLI runner with no database file present
When I invoke session list with missing db
Then the session list missing db command should exit successfully
And the session list missing db output should not contain "AttributeError"
@tdd_expected_fail
Scenario: Session list with missing database produces valid JSON
Given a CLI runner with no database file present
When I invoke session list with missing db and format json
Then the session list missing db command should exit successfully
And the session list missing db output should be valid JSON
@tdd_expected_fail
Scenario: Session list with missing database shows empty list
Given a CLI runner with no database file present
When I invoke session list with missing db
+5 -6
View File
@@ -1,9 +1,8 @@
*** Settings ***
Documentation Integration smoke test for session create DI error (bug #570).
... TDD-style tests — expected to FAIL until the DI container fix
... lands. The bug is that ``_get_session_service()`` calls
... ``container.db()`` but the DI container has no ``db`` provider,
... causing an ``AttributeError``. Same root cause as bug #554.
... Regression tests verifying that the DI container fix for
... session create works correctly. Bug #570 is now fixed
... (same root cause as bug #554).
Resource ${CURDIR}/common.resource
Library Process
Library OperatingSystem
@@ -15,7 +14,7 @@ Session Create After Init Should Not Error
[Documentation] After agents init, session create --format plain should
... exit 0 and produce a new session rather than a DI
... AttributeError.
[Tags] tdd_bug tdd_bug_570 tdd_expected_fail
[Tags] tdd_bug tdd_bug_570
${tmpdir}= Evaluate __import__('tempfile').mkdtemp(prefix='sce_570_')
${init}= Run Process ${PYTHON} -m cleveragents init sce-test
... timeout=60s cwd=${tmpdir}
@@ -31,7 +30,7 @@ Session Create After Init Should Not Error
Session Create Then List Shows Created Session
[Documentation] After creating a session, listing should show it.
[Tags] tdd_bug tdd_bug_570 tdd_expected_fail
[Tags] tdd_bug tdd_bug_570
${tmpdir}= Evaluate __import__('tempfile').mkdtemp(prefix='sce_570_list_')
${init}= Run Process ${PYTHON} -m cleveragents init sce-list
... timeout=60s cwd=${tmpdir}
+4 -6
View File
@@ -1,9 +1,7 @@
*** Settings ***
Documentation Integration smoke test for session list DI error (bug #554).
... TDD-style tests — expected to FAIL until the DI container fix
... lands. The bug is that ``_get_session_service()`` calls
... ``container.db()`` but the DI container has no ``db`` provider,
... causing an ``AttributeError``.
... Regression tests verifying that the DI container fix for
... session list works correctly. Bug #554 is now fixed.
Resource ${CURDIR}/common.resource
Library Process
Library OperatingSystem
@@ -22,7 +20,7 @@ Suite Teardown Cleanup Test Environment
Session List After Init Should Not Error
[Documentation] After agents init, session list should exit 0 and show
... "No sessions found" rather than a DI AttributeError.
[Tags] tdd_bug tdd_bug_554 tdd_expected_fail
[Tags] tdd_bug tdd_bug_554
${tmpdir}= Evaluate __import__('tempfile').mkdtemp(prefix='sle_554_')
${init}= Run Process ${PYTHON} -m cleveragents init sle-test
... timeout=60s cwd=${tmpdir}
@@ -41,7 +39,7 @@ Session List After Init Should Not Error
Session List JSON Format Does Not Error
[Documentation] session list --format json should exit 0 without raising
... a DI AttributeError.
[Tags] tdd_bug tdd_bug_554 tdd_expected_fail
[Tags] tdd_bug tdd_bug_554
${tmpdir}= Evaluate __import__('tempfile').mkdtemp(prefix='sle_554_json_')
${init}= Run Process ${PYTHON} -m cleveragents init sle-json
... timeout=60s cwd=${tmpdir}
+3 -3
View File
@@ -12,7 +12,7 @@ ${HELPER} ${CURDIR}/helper_tdd_session_create_di.py
*** Test Cases ***
TDD Session Create DI Error Via CLI
[Documentation] Verify that ``session create`` triggers the DI db error
[Tags] tdd_bug tdd_bug_570 tdd_expected_fail
[Tags] tdd_bug tdd_bug_570
${result}= Run Process ${PYTHON} ${HELPER} create-di-error cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
@@ -21,7 +21,7 @@ TDD Session Create DI Error Via CLI
TDD Session Create With Actor DI Error
[Documentation] Verify that ``session create --actor`` triggers the DI db error
[Tags] tdd_bug tdd_bug_570 tdd_expected_fail
[Tags] tdd_bug tdd_bug_570
${result}= Run Process ${PYTHON} ${HELPER} create-actor cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
@@ -30,7 +30,7 @@ TDD Session Create With Actor DI Error
TDD Session Create DI JSON Output
[Documentation] Verify that ``session create --format json`` fails due to DI db error
[Tags] tdd_bug tdd_bug_570 tdd_expected_fail
[Tags] tdd_bug tdd_bug_570
${result}= Run Process ${PYTHON} ${HELPER} create-json cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
+4 -4
View File
@@ -2,7 +2,7 @@
Documentation TDD Bug #554 — session list DI container missing db provider
... Integration smoke tests verifying that the session list command
... succeeds once the DI container has a proper ``db`` provider.
... Tagged ``tdd_expected_fail`` until bug #554 is resolved.
... Bug #554 is now fixed; these tests serve as regression tests.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
@@ -13,7 +13,7 @@ ${HELPER} ${CURDIR}/helper_tdd_session_list_di.py
*** Test Cases ***
TDD Session List DI Error Via CLI
[Documentation] Verify that ``session list`` succeeds via the real DI path
[Tags] tdd_bug tdd_bug_554 tdd_expected_fail
[Tags] tdd_bug tdd_bug_554
${result}= Run Process ${PYTHON} ${HELPER} list-di-error cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
@@ -22,7 +22,7 @@ TDD Session List DI Error Via CLI
TDD Session List DI Service Resolution
[Documentation] Verify that ``_get_session_service()`` resolves a valid service
[Tags] tdd_bug tdd_bug_554 tdd_expected_fail
[Tags] tdd_bug tdd_bug_554
${result}= Run Process ${PYTHON} ${HELPER} service-resolution cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
@@ -31,7 +31,7 @@ TDD Session List DI Service Resolution
TDD Session List DI JSON Output
[Documentation] Verify that ``session list --format json`` succeeds via the real DI path
[Tags] tdd_bug tdd_bug_554 tdd_expected_fail
[Tags] tdd_bug tdd_bug_554
${result}= Run Process ${PYTHON} ${HELPER} list-json cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
+3 -3
View File
@@ -4,7 +4,7 @@ Documentation TDD Bug #680 — session list with missing database
... handles a missing database gracefully. The database URL points
... to a non-existent file; the command should auto-create the DB
... and return an empty list rather than crashing.
... Tagged ``tdd_expected_fail`` until bug #680 is resolved.
... Bug #680 is now fixed; these tests serve as regression tests.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
@@ -15,7 +15,7 @@ ${HELPER} ${CURDIR}/helper_tdd_session_list_missing_db.py
*** Test Cases ***
TDD Session List Missing DB Via CLI
[Documentation] Verify that ``session list`` succeeds when no database file exists
[Tags] tdd_bug tdd_bug_680 tdd_expected_fail
[Tags] tdd_bug tdd_bug_680
${result}= Run Process ${PYTHON} ${HELPER} list-missing-db cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
@@ -24,7 +24,7 @@ TDD Session List Missing DB Via CLI
TDD Session List Missing DB JSON Output
[Documentation] Verify that ``session list --format json`` succeeds when no database file exists
[Tags] tdd_bug tdd_bug_680 tdd_expected_fail
[Tags] tdd_bug tdd_bug_680
${result}= Run Process ${PYTHON} ${HELPER} list-missing-db-json cwd=${WORKSPACE} timeout=30s
Log ${result.stdout}
Log ${result.stderr}
+54
View File
@@ -47,6 +47,9 @@ from cleveragents.application.services.resource_file_watcher import (
from cleveragents.application.services.resource_registry_service import (
ResourceRegistryService,
)
from cleveragents.application.services.session_service import (
PersistentSessionService,
)
from cleveragents.application.services.skeleton_compressor import (
SkeletonCompressorService,
)
@@ -74,6 +77,8 @@ from cleveragents.infrastructure.database.repositories import (
CheckpointRepository,
NamespacedProjectRepository,
ProjectResourceLinkRepository,
SessionMessageRepository,
SessionRepository,
)
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
from cleveragents.infrastructure.events.reactive import ReactiveEventBus
@@ -281,6 +286,49 @@ def _build_trace_service(
return TraceService(settings=resolved_settings, repository=repository)
def _build_session_service(
database_url: str,
) -> PersistentSessionService:
"""Build a PersistentSessionService with auto-committing repositories.
The CLI operates outside a ``UnitOfWork``, so repositories are
constructed with ``auto_commit=True`` to ensure each operation
commits and closes its own database session.
Session and session-message tables are created if they do not yet
exist. Only the two session-related tables are touched this
avoids the broad ``Base.metadata.create_all()`` that would bypass
Alembic for every other model (review finding M3/M8).
"""
from sqlalchemy import create_engine
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.orm import sessionmaker
from cleveragents.infrastructure.database.models import (
Base,
SessionMessageModel,
SessionModel,
)
engine = create_engine(database_url, echo=False)
# Targeted table creation — only session tables, never the full schema.
inspector = sa_inspect(engine)
existing_tables = set(inspector.get_table_names())
tables_to_create = [
model.__table__
for model in (SessionModel, SessionMessageModel)
if model.__tablename__ not in existing_tables
]
if tables_to_create:
Base.metadata.create_all(engine, tables=tables_to_create)
factory = sessionmaker(bind=engine, expire_on_commit=False)
session_repo = SessionRepository(session_factory=factory, auto_commit=True)
message_repo = SessionMessageRepository(session_factory=factory, auto_commit=True)
return PersistentSessionService(session_repo, message_repo)
class Container(containers.DeclarativeContainer):
"""Dependency injection container using dependency-injector.
@@ -446,6 +494,12 @@ class Container(containers.DeclarativeContainer):
settings=settings,
)
# Session Service - database-backed session management (Forgejo #554, #570, #680)
session_service = providers.Factory(
_build_session_service,
database_url=database_url,
)
# Metrics Emitter - structured metric emission (Forgejo #579)
metrics_emitter = providers.Singleton(
MetricsEmitter.from_settings,
+70 -19
View File
@@ -10,10 +10,11 @@ task A7.cli.
from __future__ import annotations
import json
import logging
import sys
from collections import OrderedDict
from pathlib import Path
from typing import Annotated, Any
from typing import Annotated, Any, cast
import typer
from rich.console import Console
@@ -21,6 +22,7 @@ from rich.panel import Panel
from rich.table import Table
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
@@ -34,6 +36,8 @@ from cleveragents.domain.models.core.session import (
app = typer.Typer(help="Manage interactive sessions.")
console = Console()
_log = logging.getLogger(__name__)
# Reusable --format option description
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
@@ -46,27 +50,19 @@ _service: SessionService | None = None
def _get_session_service() -> SessionService:
"""Get or create the SessionService instance.
Production usage goes through the DI container; tests can patch
``_service`` or this function directly.
Production usage goes through the DI container's ``session_service``
provider; tests can patch ``_service`` or this function directly.
"""
global _service
if _service is not None:
return _service
from cleveragents.application.container import get_container
from cleveragents.application.services.session_service import (
PersistentSessionService,
)
from cleveragents.infrastructure.database.repositories import (
SessionMessageRepository,
SessionRepository,
)
container = get_container()
db = container.db()
session_repo = SessionRepository(db)
message_repo = SessionMessageRepository(db)
return PersistentSessionService(session_repo, message_repo)
svc = cast(SessionService, container.session_service())
Outdated
Review

F1 (P1:must-fix)# type: ignore[assignment] violates CONTRIBUTING.md line 548 ("Never use # type: ignore in src/").

Fix: svc = cast(SessionService, container.session_service()) with from typing import cast at the top.

F2 (P1:must-fix) — Also, catching AttributeError in this function's callers is now unnecessary since container.session_service() is a properly registered provider. The original container.db() bug is fixed by this PR. Post-fix, any AttributeError here would be a genuine programming error, not a "database unavailable" condition.

**F1 (P1:must-fix)** — `# type: ignore[assignment]` violates CONTRIBUTING.md line 548 ("Never use `# type: ignore` in `src/`"). Fix: `svc = cast(SessionService, container.session_service())` with `from typing import cast` at the top. **F2 (P1:must-fix)** — Also, catching `AttributeError` in this function's callers is now unnecessary since `container.session_service()` is a properly registered provider. The original `container.db()` bug is fixed by this PR. Post-fix, any `AttributeError` here would be a genuine programming error, not a "database unavailable" condition.
_service = svc
return _service
def _reset_session_service() -> None:
@@ -141,7 +137,7 @@ def create(
data = _session_summary_dict(session)
if fmt != OutputFormat.RICH.value:
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
typer.echo(format_output(dict(data), fmt))
return
@@ -157,6 +153,13 @@ def create(
except SessionNotFoundError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1) from exc
except DatabaseError as exc:
_log.debug("session create failed", exc_info=True)
console.print(
f"[red]Error:[/red] Database unavailable: {exc}\n"
"Hint: run 'agents init' to initialise the database."
)
raise typer.Exit(1) from exc
@app.command("list")
@@ -175,17 +178,30 @@ def list_sessions(
agents session list --format json
agents session list --format table
"""
service = _get_session_service()
sessions = service.list()
try:
service = _get_session_service()
sessions = service.list()
except DatabaseError as exc:
_log.debug("session list failed", exc_info=True)
console.print(
f"[red]Error:[/red] Database unavailable: {exc}\n"
"Hint: run 'agents init' to initialise the database."
)
raise typer.Exit(1) from exc
if not sessions:
# For machine-readable formats, always emit a structured empty list
# so that callers parsing JSON/YAML receive valid output.
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
typer.echo(format_output({"sessions": [], "total": 0}, fmt))
return
console.print("[yellow]No sessions found.[/yellow]")
console.print("Create one with 'agents session create'")
return
data = _session_list_dict(sessions)
if fmt != OutputFormat.RICH.value:
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
typer.echo(format_output(data, fmt))
return
1
@@ -239,7 +255,7 @@ def show(
session = service.get(session_id)
data = session.as_cli_dict()
if fmt != OutputFormat.RICH.value:
if fmt not in (OutputFormat.RICH.value, OutputFormat.COLOR.value):
typer.echo(format_output(dict(data), fmt))
return
@@ -311,6 +327,13 @@ def show(
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
raise typer.Exit(1) from exc
except DatabaseError as exc:
_log.debug("session show failed", exc_info=True)
console.print(
f"[red]Error:[/red] Database unavailable: {exc}\n"
"Hint: run 'agents init' to initialise the database."
)
raise typer.Exit(1) from exc
@app.command()
@@ -350,6 +373,13 @@ def delete(
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
raise typer.Exit(1) from exc
except DatabaseError as exc:
_log.debug("session delete failed", exc_info=True)
console.print(
f"[red]Error:[/red] Database unavailable: {exc}\n"
"Hint: run 'agents init' to initialise the database."
)
raise typer.Exit(1) from exc
@app.command("export")
@@ -402,6 +432,13 @@ def export_session(
except SessionExportError as exc:
console.print(f"[red]Export error:[/red] {exc}")
raise typer.Exit(1) from exc
except DatabaseError as exc:
_log.debug("session export failed", exc_info=True)
console.print(
f"[red]Error:[/red] Database unavailable: {exc}\n"
"Hint: run 'agents init' to initialise the database."
)
raise typer.Exit(1) from exc
@app.command("import")
@@ -446,6 +483,13 @@ def import_session(
except SessionImportError as exc:
console.print(f"[red]Import error:[/red] {exc}")
raise typer.Exit(1) from exc
except DatabaseError as exc:
_log.debug("session import failed", exc_info=True)
console.print(
f"[red]Error:[/red] Database unavailable: {exc}\n"
"Hint: run 'agents init' to initialise the database."
)
raise typer.Exit(1) from exc
@app.command()
@@ -514,3 +558,10 @@ def tell(
except SessionNotFoundError as exc:
console.print(f"[red]Session not found:[/red] {session_id}")
raise typer.Exit(1) from exc
except DatabaseError as exc:
_log.debug("session tell failed", exc_info=True)
console.print(
f"[red]Error:[/red] Database unavailable: {exc}\n"
"Hint: run 'agents init' to initialise the database."
)
raise typer.Exit(1) from exc
@@ -3869,13 +3869,28 @@ class SessionRepository:
Uses a session-factory pattern: each public method obtains its own
session from the factory, ensuring proper session lifecycle management.
All mutating methods flush (but do NOT commit); the caller or a
``UnitOfWork`` wrapper is responsible for committing the transaction.
All mutating methods flush (but do NOT commit) by default; the caller
or a ``UnitOfWork`` wrapper is responsible for committing the
transaction. When ``auto_commit`` is ``True`` (e.g. CLI usage outside
a UoW), each method commits and closes its own session.
"""
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""Initialise with a callable that returns a new SQLAlchemy Session."""
def __init__(
self,
session_factory: Callable[[], Session],
*,
auto_commit: bool = False,
) -> None:
"""Initialise with a callable that returns a new SQLAlchemy Session.
Args:
session_factory: Factory returning a new SQLAlchemy ``Session``.
auto_commit: When ``True``, each public method commits and
closes its session automatically. Useful for CLI commands
that operate outside a ``UnitOfWork``.
"""
self._session_factory = session_factory
self._auto_commit = auto_commit
def _session(self) -> Session:
"""Convenience helper to obtain a session."""
@@ -3899,6 +3914,8 @@ class SessionRepository:
db_model = SessionModel.from_domain(session)
db_session.add(db_model)
db_session.flush()
if self._auto_commit:
db_session.commit()
return session
except IntegrityError as exc:
db_session.rollback()
@@ -3906,6 +3923,9 @@ class SessionRepository:
except (OperationalError, SQLAlchemyDatabaseError) as exc:
db_session.rollback()
raise DatabaseError(f"Failed to create session: {exc}") from exc
finally:
if self._auto_commit:
db_session.close()
@database_retry
def get_by_id(self, session_id: str) -> Any | None:
@@ -3924,6 +3944,9 @@ class SessionRepository:
return row.to_domain()
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(f"Failed to get session {session_id}: {exc}") from exc
finally:
if self._auto_commit:
db_session.close()
@database_retry
def list_all(self, actor_name: str | None = None) -> list[Any]:
@@ -3944,6 +3967,9 @@ class SessionRepository:
return [row.to_domain() for row in rows]
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(f"Failed to list sessions: {exc}") from exc
finally:
if self._auto_commit:
db_session.close()
@database_retry
def delete(self, session_id: str) -> bool:
@@ -3964,12 +3990,17 @@ class SessionRepository:
return False
db_session.delete(row)
db_session.flush()
if self._auto_commit:
db_session.commit()
return True
except (OperationalError, SQLAlchemyDatabaseError) as exc:
db_session.rollback()
raise DatabaseError(
f"Failed to delete session {session_id}: {exc}"
) from exc
finally:
if self._auto_commit:
db_session.close()
@database_retry
def update(self, session: Any) -> Any:
@@ -4015,12 +4046,17 @@ class SessionRepository:
row.updated_at = session.updated_at.isoformat() # type: ignore[assignment]
db_session.flush()
if self._auto_commit:
db_session.commit()
return session
except (OperationalError, SQLAlchemyDatabaseError) as exc:
db_session.rollback()
raise DatabaseError(
f"Failed to update session {session.session_id}: {exc}"
) from exc
finally:
if self._auto_commit:
db_session.close()
# ---------------------------------------------------------------------------
@@ -4032,12 +4068,25 @@ class SessionMessageRepository:
"""Repository for session message persistence.
Uses a session-factory pattern matching ``ActionRepository``.
All mutating methods flush but do NOT commit.
All mutating methods flush but do NOT commit by default. When
``auto_commit`` is ``True``, each method commits and closes its
own session (for CLI usage outside a ``UnitOfWork``).
"""
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""Initialise with a callable that returns a new SQLAlchemy Session."""
def __init__(
self,
session_factory: Callable[[], Session],
*,
auto_commit: bool = False,
) -> None:
"""Initialise with a callable that returns a new SQLAlchemy Session.
Args:
session_factory: Factory returning a new SQLAlchemy ``Session``.
auto_commit: When ``True``, commit and close after each op.
"""
self._session_factory = session_factory
self._auto_commit = auto_commit
def _session(self) -> Session:
"""Convenience helper to obtain a session."""
@@ -4060,12 +4109,17 @@ class SessionMessageRepository:
db_model.session_id = session_id # type: ignore[assignment]
db_session.add(db_model)
db_session.flush()
if self._auto_commit:
db_session.commit()
return message
except (OperationalError, SQLAlchemyDatabaseError) as exc:
db_session.rollback()
raise DatabaseError(
f"Failed to append message to session {session_id}: {exc}"
) from exc
finally:
if self._auto_commit:
db_session.close()
@database_retry
def get_for_session(
@@ -4100,6 +4154,9 @@ class SessionMessageRepository:
raise DatabaseError(
f"Failed to get messages for session {session_id}: {exc}"
) from exc
finally:
if self._auto_commit:
db_session.close()
@database_retry
def count_for_session(self, session_id: str) -> int:
@@ -4122,6 +4179,9 @@ class SessionMessageRepository:
raise DatabaseError(
f"Failed to count messages for session {session_id}: {exc}"
) from exc
finally:
if self._auto_commit:
db_session.close()
# AutomationProfile Repository Errors