Compare commits

..

1 Commits

Author SHA1 Message Date
HAL9000 e9b9919d94 feat(cli): implement context list and context add CLI commands for ACMS
Add new acms context list and acms context add CLI commands that
interact with the ACMS (Application Context Management System) tier-based
context pipeline. These commands manage context fragments across the hot,
warm, and cold storage tiers via the ContextTierService:

- acms context list: List all ACMS context fragments with tier placement,
  token counts, access frequency, and metrics. Supports --tier, --project,
  --project-filter, and format options (rich/json/yaml/table/plain).

- acms context add: Add resource files/directories to the ACMS tier store
  as TieredFragment entries under a target project scope and tier. Skips
  binary and oversized files automatically. Supports --tier, --recursive,
  --project, and format options. Also includes an reset subcommand to
  clear all fragments for a project.
2026-05-07 19:19:08 +00:00
38 changed files with 583 additions and 890 deletions
-59
View File
@@ -5,57 +5,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
- Fixed `ReactiveEventBus.emit()` exception handler to log the full exception
message (`str(exc)`) and enable traceback forwarding (`exc_info=True`).
Previously the handler logged only the exception type name (e.g.
"ValueError") with no diagnostic detail, making production debugging
impossible. The handler now includes the error message text and full
traceback in the structlog warning entry. Removed `@tdd_expected_fail` tag
from the TDD test so both scenarios run as normal regression guards. (#988)
### Fixed
- **Actor CLI NAME argument made optional, derived from YAML config** (#4186): The
`agents actor add` positional ``NAME`` argument is now optional (defaults to
``None``). When omitted, the actor name is derived from the ``name`` field in
the config file. Raises ``BadParameter`` if neither the argument nor the config
``name`` field is provided. Updated docstring signature to
``agents actor add [--config|-c <FILE>] [<NAME>]`` and added config-only usage
examples. Added Behave scenario for the ``BadParameter`` error path
(``actor add without NAME and without config name field raises BadParameter``)
in ``features/actor_add_name_positional.feature`` with corresponding step
definition. Updated step definitions in
``features/steps/actor_add_update_enforcement_steps.py`` and
``features/steps/actor_add_name_positional_steps.py`` to pass ``context.actor_name``
as a positional argument for compatibility.
- **Improved parallel test suite isolation** (#4186): Replaced deprecated
``tempfile.mktemp`` with ``tempfile.mkstemp`` in ``features/environment.py``
for atomic temp file creation, eliminating TOCTOU race conditions in the
per-scenario database path generation. Added ``fcntl.flock`` file locking to
``_ensure_template_db()`` to prevent race conditions when multiple
``behave-parallel`` workers attempt to create the template database
simultaneously.
- **Removed stale @tdd_expected_fail tags from actor add enforcement tests**: The
``--update`` enforcement feature (#2609) was already implemented and merged but
residual ``@tdd_expected_fail`` tags remained on its BDD scenarios. These tags
were cleaned up in ``features/actor_add_update_enforcement.feature`` so the
tests report correctly now that the underlying bug has been fixed.
- **Resolved Behave AmbiguousStep collisions in step definitions** (#4186): Renamed
step texts to avoid case-sensitive collisions between different step modules that
prevented all Behave tests from loading. Renamed steps in
``edge_case_plan_steps.py``, ``plan_executor_coverage_boost_steps.py``,
``plan_explain_steps.py``, ``plan_model_steps.py``, ``project_repository_steps.py``,
``service_retry_wiring_steps.py``, and ``session_model_steps.py``.
Additionally resolved a collision between ``acms_index_data_model_traversal_steps.py``
and ``security_audit_steps.py`` for ``Then the count should be``, and fixed
``pr_compliance_checklist_steps.py`` project-root resolution (``parents[3]`` →
``parents[2]``). Fixed table column-header mismatches in
``features/acms/index_data_model_and_traversal.feature`` and guarded
``cli_init_yes_flag_steps.py`` cleanup against ``None`` temp_dir. Annotated
``features/architecture.feature`` ``@tdd_expected_fail`` for pre-existing Pydantic
compliance debt in ``IndexEntry`` / ``ACMSIndex`` classes.
- **Cross-actor subgraph cycle detection reads actor_ref field** (#1431): Fixed
`_detect_subgraph_cycles()`, `_map_node()`, and the `compile_actor()` main loop
@@ -67,15 +17,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
infinite recursion at runtime. Added Behave regression tests
(`features/actor_subgraph_cycle_detection.feature`) and a Robot Framework
integration test (`robot/actor_compiler.robot`) to prevent regressions.
- **Devcontainer auto-discovery wired into `git-checkout`/`fs-directory` handlers** (#4740):
`GitCheckoutHandler.discover_children()` and `FsDirectoryHandler.discover_children()` now
call `discover_devcontainers()` after scanning for `fs-directory` children. Any
`.devcontainer/devcontainer.json` or root-level `.devcontainer.json` found at the resource
location is registered as a `devcontainer-instance` child resource with
`provisioning_state: discovered`. Named configurations (`.devcontainer/<name>/devcontainer.json`)
are also discovered and carry the configuration name in the `config_name` property.
This wires the previously-isolated `discover_devcontainers()` function into the production
code path, enabling the spec's zero-configuration devcontainer experience.
### Changed
-1
View File
@@ -1 +0,0 @@
# Clone verification placeholder
+19 -16
View File
@@ -57,32 +57,35 @@ inherits. Represents a generic container execution environment.
| Child types | (none) |
| Handler | `DevcontainerHandler` |
## Auto-Discovery
## Auto-Discovery (Planned)
Auto-discovery is wired into `GitCheckoutHandler.discover_children()` and
`FsDirectoryHandler.discover_children()` (issue #4740). When either handler
scans a resource location, it calls `discover_devcontainers()` after
discovering `fs-directory` children.
> **Not yet wired (F31/F23):** The discovery module
> (`discover_devcontainers()`) exists and is tested in isolation, but
> is **not** invoked during `project link-resource` or any other
> production code path. Auto-discovery will be wired in a follow-up PR.
> For now, devcontainer-instance resources must be added manually via
> `agents resource add`.
Devcontainer configurations are detected at the following locations
(relative to the resource root):
When wired, linking a `git-checkout` or `fs-directory` resource to a
project will trigger an auto-discovery hook that scans for devcontainer
configurations in the following locations (relative to the resource
root):
1. `.devcontainer/devcontainer.json`
2. `.devcontainer.json` (root-level)
3. `.devcontainer/<name>/devcontainer.json` (named configurations)
### Discovery Process
### Discovery Process (Planned)
1. **Trigger**: `discover_children()` is called on a `git-checkout` or
`fs-directory` resource.
2. **Scan**: `discover_devcontainers()` checks for configuration files at
the well-known paths listed above.
1. **Trigger**: Linking a `git-checkout` or `fs-directory` resource.
2. **Scan**: The discovery module checks for configuration files at the
well-known paths listed above.
3. **Validate**: Each discovered file is parsed as JSON. Invalid files
are skipped with a warning.
4. **Register**: For each valid configuration:
- A `devcontainer-instance` child resource is created under the
parent resource with `provisioning_state: discovered`.
- Named configurations carry the subdirectory name as `config_name`.
parent resource.
- A `devcontainer-file` child resource is created under the
devcontainer instance, pointing to the JSON file.
### Lazy Activation
@@ -247,7 +250,7 @@ confirmation unless `--yes` (`-y`) is passed.
| Registry eviction | Terminal-state trackers (stopped/failed) are evicted when count exceeds 200 (`_MAX_TERMINAL_TRACKERS`). Eviction runs automatically after bulk cleanup via `stop_all_active_containers`. Long-running processes with many container cycles may lose historical tracker data. | Acceptable for MVP; persisted state in M7+ eliminates this. |
| Sandbox strategy (F22/F25) | Specification uses `container_snapshot`; `SandboxFactory` does not yet implement `snapshot`. Handler now uses `SandboxStrategy.NONE` — the container itself provides isolation. | Implement `container_snapshot` in `SandboxFactory` and switch handler back to it. |
| Hardcoded `docker stop` (F21) | `stop_container()` calls `docker stop` directly. Devcontainer CLI can target Podman or other engines; the devcontainer CLI does not yet offer a `stop` subcommand. | Detect the container engine from resource properties or devcontainer CLI config and dispatch to the appropriate stop command. |
| Auto-discovery wired (F23 resolved) | `discover_devcontainers()` is now called from `GitCheckoutHandler.discover_children()` and `FsDirectoryHandler.discover_children()`. Devcontainer configs are auto-detected when `discover_children()` is invoked. | Completed in issue #4740. |
| Auto-discovery not wired (F23) | Documentation describes auto-discovery on `project link-resource`, but no code path invokes `discover_devcontainers()` during resource linking. | Wire the discovery hook into the resource linking flow. |
| Container execution stubbed (F24) | `ToolRunner` returns an error for `ExecutionEnvironment.CONTAINER`, so lazy activation cannot trigger via tool use in production. Lazy activation currently works through `DevcontainerHandler.resolve()` on plan sandbox resolution. | Full container execution support is scoped for a follow-up PR (#616). |
## DevcontainerHandler Protocol Methods
@@ -9,10 +9,10 @@ Feature: ACMS Index Data Model and File Traversal Engine
Scenario: Create an index entry with file metadata
When I create an index entry with:
| key | value |
| path | /project/src/main.py |
| file_type | python |
| size_bytes | 1024 |
| key | value |
| path | /project/src/main.py |
| file_type | python |
| size_bytes | 1024 |
Then the index entry should have path "/project/src/main.py"
And the index entry should have file type "python"
And the index entry should have size 1024 bytes
@@ -117,7 +117,7 @@ Feature: ACMS Index Data Model and File Traversal Engine
Scenario: Get entry count from index
Given I have an index with 10 entries
When I get the entry count
Then idx the index count should be 10
Then the ACMS entry count should be 10
Scenario: Remove entry from index
Given I have an index with 3 entries
@@ -132,9 +132,9 @@ Feature: ACMS Index Data Model and File Traversal Engine
| /project/tests/test_main.py | python | test | cold |
| /project/docs/readme.md | markdown | docs | cold |
When I query the index with filters:
| filter | value |
| path_pattern | src |
| file_type | python |
| tier | hot |
| filter | value |
| path_pattern | src |
| file_type | python |
| tier | hot |
Then I should get 1 result
And the result should have path "/project/src/main.py"
+3 -11
View File
@@ -1,7 +1,7 @@
Feature: agents actor add NAME positional argument
As a user of the CleverAgents CLI
I want to pass the actor name as a positional argument to `agents actor add`
So that the CLI matches the spec synopsis: agents actor add --config <FILE> [<NAME>]
So that the CLI matches the spec synopsis: agents actor add <NAME> --config <FILE>
@tdd_issue @tdd_issue_4230 @tdd_expected_fail
Scenario: actor add accepts NAME as positional argument
@@ -17,16 +17,8 @@ Feature: agents actor add NAME positional argument
When I run actor add with NAME positional argument overriding config name
Then the actor add should use the positional NAME not the config name
@tdd_issue @tdd_issue_4186
Scenario: actor add without NAME positional argument uses config name
Given an actor CLI runner
And I have an actor JSON config file with name "local/config-derived-actor"
When I run actor add with config but no NAME positional argument
Then the actor add should succeed using the config name
@tdd_issue @tdd_issue_4186
Scenario: actor add without NAME and without config name field raises BadParameter
Scenario: actor add without NAME positional argument fails
Given an actor CLI runner
And I have an actor JSON config file without a name field
When I run actor add with config but no NAME positional argument
Then the actor add should fail with a BadParameter error about missing actor name
Then the actor command should fail with missing argument error
@@ -5,7 +5,7 @@ Feature: agents actor add enforces --update flag for existing actors
I want `agents actor add` to fail with a clear error when re-adding an existing actor
So that I cannot accidentally overwrite actor configurations without explicit intent
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
Scenario: Re-adding an existing actor without --update fails with error panel
Given an actor add CLI runner where the actor already exists
When I run actor add without the --update flag
@@ -14,7 +14,7 @@ Feature: agents actor add enforces --update flag for existing actors
And the actor-add-enforcement output should contain "Use --update to replace the existing actor definition"
And the actor-add-enforcement output should contain the registration timestamp
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
Scenario: Re-adding an existing actor without --update shows error status line
Given an actor add CLI runner where the actor already exists
When I run actor add without the --update flag
@@ -22,14 +22,14 @@ Feature: agents actor add enforces --update flag for existing actors
And the actor-add-enforcement output should contain "Actor already registered"
And the actor-add-enforcement output should contain "use --update to replace"
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
Scenario: Re-adding an existing actor with --update succeeds
Given an actor add CLI runner where the actor already exists
When I run actor add with the --update flag
Then the actor-add-enforcement exit code should be 0
And the actor-add-enforcement output should contain "Actor updated"
@tdd_issue @tdd_issue_2609 @tdd_issue_4178
@tdd_issue @tdd_issue_2609 @tdd_expected_fail @tdd_issue_4178
Scenario: Adding a new actor without --update succeeds
Given an actor add CLI runner where the actor does not exist
When I run actor add without the --update flag
-1
View File
@@ -34,7 +34,6 @@ Feature: Architecture validation
Then all application variables should use CLEVERAGENTS_ prefix
And provider variables should keep their original prefix
@tdd_issue @tdd_issue_4186
Scenario: Type hints are used throughout
Given the source code exists
When I check for type annotations
+6 -6
View File
@@ -1159,7 +1159,7 @@ Feature: Consolidated Domain Models
Scenario: CLI dict has required fields
Given a session with some messages
When I get the session model CLI dict
When I get the session CLI dict
Then the session cli dict should have key "session_summary"
And the session cli dict should have key "token_usage"
And the session cli dict session_summary should have key "id"
@@ -1170,34 +1170,34 @@ Feature: Consolidated Domain Models
Scenario: CLI dict includes recent messages
Given a session with some messages
When I get the session model CLI dict
When I get the session CLI dict
Then the session cli dict should have key "recent_messages"
And the session cli dict recent_messages text key should be "text"
Scenario: CLI dict includes actor when set
When I create a session with actor name "local/orchestrator"
And I get the session model CLI dict
And I get the session CLI dict
Then the session cli dict session_summary should have key "actor"
Scenario: CLI dict includes automation when set
When I create a session with automation "review"
And I get the session model CLI dict
And I get the session CLI dict
Then the session cli dict session_summary should have key "automation"
And the session cli dict session_summary automation should be "review"
Scenario: CLI dict linked_plans uses spec-compliant objects
Given a session with linked plans
When I get the session model CLI dict
When I get the session CLI dict
Then the session cli dict should have key "linked_plans"
And the session cli dict linked_plans should contain plan_id field
Scenario: CLI dict token_usage estimated_cost is formatted string
Given a session with token usage cost 0.0184
When I get the session model CLI dict
When I get the session CLI dict
Then the session cli dict token_usage estimated_cost should be a string starting with "$"
# ---- Empty Session Properties ----
@@ -352,24 +352,24 @@ Feature: Consolidated Plan Model Lifecycle
Scenario: New plan starts in STRATEGIZE with QUEUED processing state
Given I create a PlanModel in strategize phase
Given I create a plan in strategize phase
Then the plan phase should be "strategize"
And the plan processing state should be "queued"
Scenario: Strategize phase uses ProcessingState
Given I create a PlanModel in strategize phase
Given I create a plan in strategize phase
Then the plan processing state should be "queued"
Scenario: Plan in strategize phase defaults to QUEUED processing state
Given I create a PlanModel in strategize phase
Given I create a plan in strategize phase
Then the plan processing state should be "queued"
And the plan state should be "queued"
Scenario: Strategize phase defaults processing_state to QUEUED
Given I create a PlanModel in strategize phase
Given I create a plan in strategize phase
Then the plan processing state should be "queued"
# Plan Identity Tests
@@ -401,12 +401,12 @@ Feature: Consolidated Plan Model Lifecycle
Scenario: Plan in STRATEGIZE with QUEUED cannot transition
Given I create a PlanModel in strategize phase
Given I create a plan in strategize phase
Then the plan cannot transition to next phase
Scenario: New plan in STRATEGIZE with QUEUED cannot transition
Given I create a PlanModel in strategize phase
Given I create a plan in strategize phase
Then the plan cannot transition to next phase
@@ -455,17 +455,17 @@ Feature: Consolidated Plan Model Lifecycle
Scenario: Plan in errored state is_errored returns True
Given I create a PlanModel in strategize phase with errored state
Given I create a plan in strategize phase with errored state
Then the plan should be errored
Scenario: Plan in processing state is_errored returns False
Given I create a PlanModel in strategize phase with processing state
Given I create a plan in strategize phase with processing state
Then the plan should not be errored
Scenario: Cancelled plan is terminal
Given I create a PlanModel in strategize phase with cancelled state
Given I create a plan in strategize phase with cancelled state
Then the plan should be terminal
# Model Validation Tests
@@ -1,62 +0,0 @@
Feature: Devcontainer auto-discovery wired into git-checkout and fs-directory handlers
As a CleverAgents user
I want devcontainer configurations to be automatically discovered
When I register a git-checkout or fs-directory resource
So that devcontainer-instance child resources are created without manual intervention
Scenario: git-checkout discover_children finds root devcontainer config
Given dcwire a git repo with a ".devcontainer/devcontainer.json" file
When dcwire I call discover_children on the git-checkout resource
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
And dcwire the devcontainer child has provisioning_state "discovered"
And dcwire the devcontainer child has devcontainer_json_path set
Scenario: git-checkout discover_children finds named devcontainer config
Given dcwire a git repo with a ".devcontainer/api/devcontainer.json" named config
When dcwire I call discover_children on the git-checkout resource
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-api"
And dcwire the devcontainer child has config_name "api"
Scenario: git-checkout discover_children with no devcontainer returns only directories
Given dcwire a git repo with no devcontainer configuration
When dcwire I call discover_children on the git-checkout resource
Then dcwire no devcontainer-instance children are present
Scenario: git-checkout discover_children includes both fs-directory and devcontainer children
Given dcwire a git repo with a subdirectory "src" and a ".devcontainer/devcontainer.json" file
When dcwire I call discover_children on the git-checkout resource
Then dcwire the children include a "fs-directory" resource named "src"
And dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
Scenario: fs-directory discover_children finds root devcontainer config
Given dcwire a filesystem directory with a ".devcontainer/devcontainer.json" file
When dcwire I call discover_children on the fs-directory resource
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
And dcwire the devcontainer child has provisioning_state "discovered"
Scenario: fs-directory discover_children finds named devcontainer config
Given dcwire a filesystem directory with a ".devcontainer/frontend/devcontainer.json" named config
When dcwire I call discover_children on the fs-directory resource
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-frontend"
And dcwire the devcontainer child has config_name "frontend"
Scenario: fs-directory discover_children with no devcontainer returns only directories
Given dcwire a filesystem directory with no devcontainer configuration
When dcwire I call discover_children on the fs-directory resource
Then dcwire no devcontainer-instance children are present
Scenario: fs-directory discover_children includes both fs-directory and devcontainer children
Given dcwire a filesystem directory with a subdirectory "lib" and a ".devcontainer/devcontainer.json" file
When dcwire I call discover_children on the fs-directory resource
Then dcwire the children include a "fs-directory" resource named "lib"
And dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
Scenario: git-checkout discover_children finds root-level .devcontainer.json
Given dcwire a git repo with a root-level ".devcontainer.json" file
When dcwire I call discover_children on the git-checkout resource
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
Scenario: fs-directory discover_children finds root-level .devcontainer.json
Given dcwire a filesystem directory with a root-level ".devcontainer.json" file
When dcwire I call discover_children on the fs-directory resource
Then dcwire the children include a "devcontainer-instance" resource named "devcontainer-default"
+4 -4
View File
@@ -180,19 +180,19 @@ Feature: Plan edge case scenarios
Scenario: Plan model rejects empty description
When I try to create an edge case plan with empty description
Then an Edge Case Pydantic validation error should be raised
Then a Pydantic validation error should be raised
Scenario: Plan model rejects invalid phase value
When I try to create an edge case plan with invalid phase value
Then an Edge Case Pydantic validation error should be raised
Then a Pydantic validation error should be raised
Scenario: NamespacedName rejects invalid characters in namespace
When I try to parse a namespaced name with special characters "inv@lid/action"
Then an Edge Case Pydantic validation error should be raised
Then a Pydantic validation error should be raised
Scenario: NamespacedName rejects invalid characters in name
When I try to parse a namespaced name with special chars in name "local/my action!"
Then an Edge Case Pydantic validation error should be raised
Then a Pydantic validation error should be raised
# ──────────────────────────────────────────────────
# Section 4: Rollback edge cases
+8 -21
View File
@@ -1,7 +1,6 @@
"""Behave environment setup for feature tests."""
import contextlib
import fcntl
import logging
import os
import re
@@ -336,13 +335,13 @@ def before_all(context):
# Use per-process unique database paths so parallel test subprocesses
# (behave-parallel) never contend on the same SQLite file.
if "CLEVERAGENTS_DATABASE_URL" not in os.environ:
_fd, _db_path = tempfile.mkstemp(suffix=".db", prefix="cleveragents_")
os.close(_fd)
os.environ["CLEVERAGENTS_DATABASE_URL"] = f"sqlite:///{_db_path}"
os.environ["CLEVERAGENTS_DATABASE_URL"] = (
f"sqlite:///{tempfile.mktemp(suffix='.db', prefix='cleveragents_')}"
)
if "CLEVERAGENTS_TEST_DATABASE_URL" not in os.environ:
_fd, _db_path = tempfile.mkstemp(suffix=".db", prefix="cleveragents_test_")
os.close(_fd)
os.environ["CLEVERAGENTS_TEST_DATABASE_URL"] = f"sqlite:///{_db_path}"
os.environ["CLEVERAGENTS_TEST_DATABASE_URL"] = (
f"sqlite:///{tempfile.mktemp(suffix='.db', prefix='cleveragents_test_')}"
)
os.environ.setdefault("BEHAVE_TESTING", "true")
# Set up mock AI provider for all tests
@@ -454,15 +453,8 @@ def _ensure_template_db() -> None:
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
return
lock_path = template_path.with_suffix(".db.lock")
_lock_fd = -1
try:
_lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
fcntl.flock(_lock_fd, fcntl.LOCK_EX)
if template_path.is_file():
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
return
# Import the template creation script
scripts_dir = Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(scripts_dir))
from create_template_db import create_template
@@ -471,10 +463,6 @@ def _ensure_template_db() -> None:
os.environ["CLEVERAGENTS_TEMPLATE_DB"] = str(template_path.resolve())
except Exception:
pass # Fall back to normal Alembic migrations
finally:
if _lock_fd >= 0:
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
os.close(_lock_fd)
def _install_template_db_patch() -> None:
@@ -648,8 +636,7 @@ def before_scenario(context, scenario):
("CLEVERAGENTS_DATABASE_URL", "cleveragents_"),
("CLEVERAGENTS_TEST_DATABASE_URL", "cleveragents_test_"),
):
_fd, db_path = tempfile.mkstemp(suffix=".db", prefix=prefix)
os.close(_fd)
db_path = tempfile.mktemp(suffix=".db", prefix=prefix)
os.environ[env_var] = f"sqlite:///{db_path}"
context._scenario_db_paths.append(db_path)
@@ -32,7 +32,7 @@ Feature: PlanExecutor Coverage Boost
Scenario: _try_rollback_to_last_checkpoint returns False when no sandbox is resolvable
Given a PlanExecutor with a checkpoint manager but no sandbox source
When I attempt to rollback to the last checkpoint
Then the executor rollback result should be False
Then the rollback result should be False
# --- _resolve_sandbox_for_checkpoint via execution_context (line 458) ---
+1 -1
View File
@@ -56,7 +56,7 @@ Feature: Plan explain and decision tree CLI commands
Given a test decision for explain
When I format the explain dict as json
Then the json output should contain "decision_id"
And the plan explain json output should be valid
And the json output should be valid json
# ------------------------------------------------------------------
# plan explain - yaml format
+1 -1
View File
@@ -125,7 +125,7 @@ Feature: Namespaced project repository operations
Scenario: Remove a non-existent link returns False
When I remove a link with id "00000000000000000000000099"
Then the project repo remove result should be False
Then the remove result should be False
Scenario: Create link with read_only flag
Given a namespaced project "local/ro-proj" exists in the repository
+1 -1
View File
@@ -76,7 +76,7 @@ Feature: Retry Policy Wiring for Services
@registry
Scenario: Per-service policy defaults with config overrides
Given I have Settings with retry_service_overrides setting plan_service max_attempts to 10
When I create a ServiceRetryWiring from those retry Settings
When I create a ServiceRetryWiring from those Settings
Then the plan_service policy should have max_attempts 10
@registry
@@ -361,7 +361,7 @@ def step_get_entry_count(context):
context.entry_count = context.index.get_entry_count()
@then("idx the index count should be {count:d}")
@then("the ACMS entry count should be {count:d}")
def step_check_entry_count_value(context, count):
"""Verify the entry count matches the expected value."""
assert context.entry_count == count
@@ -11,7 +11,6 @@ from unittest.mock import MagicMock, patch
from behave import given, then, when
from cleveragents.cli.commands.actor import app as actor_app
from cleveragents.core.exceptions import NotFoundError
from cleveragents.domain.models.core.actor import Actor
@@ -76,22 +75,6 @@ def step_impl(context: Any) -> None:
_register_cleanup(context, context.actor_config_path)
@given("I have an actor JSON config file with name {name}")
def step_impl(context: Any, name: str) -> None:
context.actor_config_data = {
"name": name,
"provider": "openai",
"model": "gpt-4o-mini",
}
with tempfile.NamedTemporaryFile(
delete=False, suffix=".json", mode="w", encoding="utf-8"
) as handle:
json.dump(context.actor_config_data, handle)
handle.flush()
context.actor_config_path = Path(handle.name)
_register_cleanup(context, context.actor_config_path)
@when("I run actor add with NAME positional argument and config")
def step_impl(context: Any) -> None:
context.positional_name = "local/my-actor"
@@ -134,14 +117,8 @@ def step_impl(context: Any) -> None:
@when("I run actor add with config but no NAME positional argument")
def step_impl(context: Any) -> None:
config_name = context.actor_config_data.get("name", "local/default-actor")
mock_actor = _make_actor(name=config_name)
with patch("cleveragents.cli.commands.actor._get_services") as mock_svc:
registry = MagicMock()
registry.get_actor.side_effect = NotFoundError(
f"Actor not found: {config_name}"
)
registry.upsert_actor.return_value = mock_actor
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app,
@@ -151,7 +128,6 @@ def step_impl(context: Any) -> None:
str(context.actor_config_path),
],
)
context.mock_registry = registry
@then("the actor add should succeed with the positional name")
@@ -199,34 +175,3 @@ def step_impl(context: Any) -> None:
f"Expected non-zero exit_code, got {context.result.exit_code}.\n"
f"Output:\n{context.result.output}"
)
@then("the actor add should succeed using the config name")
def step_impl(context: Any) -> None:
assert context.result.exit_code == 0, (
f"Expected exit_code=0, got {context.result.exit_code}.\n"
f"Output:\n{context.result.output}"
)
assert context.mock_registry.upsert_actor.called, (
"Expected upsert_actor to be called on the registry"
)
call_kwargs = context.mock_registry.upsert_actor.call_args
actual_name = call_kwargs.kwargs.get("name") or (
call_kwargs.args[0] if call_kwargs.args else None
)
expected_name = context.actor_config_data.get("name")
assert actual_name == expected_name, (
f"Expected upsert_actor called with name={expected_name!r} (from config), "
f"got name={actual_name!r}"
)
@then("the actor add should fail with a BadParameter error about missing actor name")
def step_impl(context: Any) -> None:
assert context.result.exit_code != 0, (
f"Expected non-zero exit_code, got {context.result.exit_code}.\n"
f"Output:\n{context.result.output}"
)
assert "Actor name is required" in context.result.output, (
f"Expected 'Actor name is required' in output:\n{context.result.output}"
)
@@ -107,7 +107,7 @@ def step_when_add_without_update(context: Any) -> None:
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app,
["add", context.actor_name, "--config", str(context.actor_config_path)],
["add", "--config", str(context.actor_config_path)],
)
@@ -122,13 +122,7 @@ def step_when_add_with_update(context: Any) -> None:
mock_svc.return_value = (MagicMock(), registry)
context.result = context.runner.invoke(
actor_app,
[
"add",
context.actor_name,
"--config",
str(context.actor_config_path),
"--update",
],
["add", "--config", str(context.actor_config_path), "--update"],
)
@@ -1,339 +0,0 @@
"""Step definitions for devcontainer_autodiscovery_wiring.feature.
Tests that discover_devcontainers() is correctly wired into
GitCheckoutHandler.discover_children() and FsDirectoryHandler.discover_children().
All steps use the ``dcwire`` prefix to avoid Behave AmbiguousStep errors.
"""
from __future__ import annotations
import json
import subprocess
import tempfile
from pathlib import Path
from behave import given, then, when
from cleveragents.domain.models.core.resource import PhysVirt, Resource
from cleveragents.resource.handlers.fs_directory import FsDirectoryHandler
from cleveragents.resource.handlers.git_checkout import GitCheckoutHandler
_VALID_ULID = "01JQDVHN5X5QJKBMZ3AP8Y4G7K"
_DEVCONTAINER_JSON = json.dumps({"name": "Test Dev Container", "image": "ubuntu:22.04"})
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_git_resource(location: str) -> Resource:
"""Create a minimal git-checkout Resource."""
return Resource(
resource_id=_VALID_ULID,
name="test-repo",
resource_type_name="git-checkout",
classification=PhysVirt.PHYSICAL,
description="Test git checkout resource",
location=location,
)
def _make_fs_resource(location: str) -> Resource:
"""Create a minimal fs-directory Resource."""
return Resource(
resource_id=_VALID_ULID,
name="test-dir",
resource_type_name="fs-directory",
classification=PhysVirt.PHYSICAL,
description="Test filesystem directory resource",
location=location,
)
def _init_git_repo(tmpdir: str, extra_files: dict[str, str] | None = None) -> str:
"""Initialise a bare-minimum git repo with an initial commit."""
subprocess.run(["git", "init"], cwd=tmpdir, capture_output=True, check=True)
subprocess.run(
["git", "config", "user.email", "test@dcwire.dev"],
cwd=tmpdir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "DCWire Test"],
cwd=tmpdir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "commit.gpgSign", "false"],
cwd=tmpdir,
capture_output=True,
check=True,
)
# Always create a README so there is at least one tracked file
readme = Path(tmpdir) / "README.md"
readme.write_text("# dcwire test\n")
if extra_files:
for rel_path, file_content in extra_files.items():
full = Path(tmpdir) / rel_path
full.parent.mkdir(parents=True, exist_ok=True)
full.write_text(file_content)
subprocess.run(["git", "add", "."], cwd=tmpdir, capture_output=True, check=True)
subprocess.run(
["git", "commit", "-m", "init"],
cwd=tmpdir,
capture_output=True,
check=True,
)
return tmpdir
def _create_devcontainer_dir(base: str, rel_path: str) -> None:
"""Create a devcontainer.json at the given relative path inside base."""
full = Path(base) / rel_path
full.parent.mkdir(parents=True, exist_ok=True)
full.write_text(_DEVCONTAINER_JSON)
# ---------------------------------------------------------------------------
# GIVEN steps — git-checkout
# ---------------------------------------------------------------------------
@given('dcwire a git repo with a ".devcontainer/devcontainer.json" file')
def step_dcwire_git_with_root_devcontainer(ctx):
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-")
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
_init_git_repo(tmpdir, {".devcontainer/devcontainer.json": _DEVCONTAINER_JSON})
ctx.dcwire_handler = GitCheckoutHandler()
ctx.dcwire_resource = _make_git_resource(tmpdir)
ctx.dcwire_result = None
ctx.dcwire_error = None
ctx.dcwire_last_dc_child = None
@given('dcwire a git repo with a ".devcontainer/api/devcontainer.json" named config')
def step_dcwire_git_with_named_devcontainer(ctx):
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-named-")
_create_devcontainer_dir(tmpdir, ".devcontainer/api/devcontainer.json")
_init_git_repo(tmpdir, {".devcontainer/api/devcontainer.json": _DEVCONTAINER_JSON})
ctx.dcwire_handler = GitCheckoutHandler()
ctx.dcwire_resource = _make_git_resource(tmpdir)
ctx.dcwire_result = None
ctx.dcwire_error = None
ctx.dcwire_last_dc_child = None
@given("dcwire a git repo with no devcontainer configuration")
def step_dcwire_git_no_devcontainer(ctx):
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-nodc-")
_init_git_repo(tmpdir)
ctx.dcwire_handler = GitCheckoutHandler()
ctx.dcwire_resource = _make_git_resource(tmpdir)
ctx.dcwire_result = None
ctx.dcwire_error = None
ctx.dcwire_last_dc_child = None
@given(
'dcwire a git repo with a subdirectory "src" and a ".devcontainer/devcontainer.json" file'
)
def step_dcwire_git_with_src_and_devcontainer(ctx):
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-src-")
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
_init_git_repo(
tmpdir,
{
"src/main.py": "print('hello')\n",
".devcontainer/devcontainer.json": _DEVCONTAINER_JSON,
},
)
ctx.dcwire_handler = GitCheckoutHandler()
ctx.dcwire_resource = _make_git_resource(tmpdir)
ctx.dcwire_result = None
ctx.dcwire_error = None
ctx.dcwire_last_dc_child = None
@given('dcwire a git repo with a root-level ".devcontainer.json" file')
def step_dcwire_git_with_root_level_devcontainer_json(ctx):
tmpdir = tempfile.mkdtemp(prefix="dcwire-git-rootdc-")
_create_devcontainer_dir(tmpdir, ".devcontainer.json")
_init_git_repo(tmpdir, {".devcontainer.json": _DEVCONTAINER_JSON})
ctx.dcwire_handler = GitCheckoutHandler()
ctx.dcwire_resource = _make_git_resource(tmpdir)
ctx.dcwire_result = None
ctx.dcwire_error = None
ctx.dcwire_last_dc_child = None
# ---------------------------------------------------------------------------
# GIVEN steps — fs-directory
# ---------------------------------------------------------------------------
@given('dcwire a filesystem directory with a ".devcontainer/devcontainer.json" file')
def step_dcwire_fs_with_root_devcontainer(ctx):
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-")
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
ctx.dcwire_handler = FsDirectoryHandler()
ctx.dcwire_resource = _make_fs_resource(tmpdir)
ctx.dcwire_result = None
ctx.dcwire_error = None
ctx.dcwire_last_dc_child = None
@given(
'dcwire a filesystem directory with a ".devcontainer/frontend/devcontainer.json" named config'
)
def step_dcwire_fs_with_named_devcontainer(ctx):
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-named-")
_create_devcontainer_dir(tmpdir, ".devcontainer/frontend/devcontainer.json")
ctx.dcwire_handler = FsDirectoryHandler()
ctx.dcwire_resource = _make_fs_resource(tmpdir)
ctx.dcwire_result = None
ctx.dcwire_error = None
ctx.dcwire_last_dc_child = None
@given("dcwire a filesystem directory with no devcontainer configuration")
def step_dcwire_fs_no_devcontainer(ctx):
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-nodc-")
ctx.dcwire_handler = FsDirectoryHandler()
ctx.dcwire_resource = _make_fs_resource(tmpdir)
ctx.dcwire_result = None
ctx.dcwire_error = None
ctx.dcwire_last_dc_child = None
@given(
'dcwire a filesystem directory with a subdirectory "lib" and a ".devcontainer/devcontainer.json" file'
)
def step_dcwire_fs_with_lib_and_devcontainer(ctx):
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-lib-")
lib_dir = Path(tmpdir) / "lib"
lib_dir.mkdir()
_create_devcontainer_dir(tmpdir, ".devcontainer/devcontainer.json")
ctx.dcwire_handler = FsDirectoryHandler()
ctx.dcwire_resource = _make_fs_resource(tmpdir)
ctx.dcwire_result = None
ctx.dcwire_error = None
ctx.dcwire_last_dc_child = None
@given('dcwire a filesystem directory with a root-level ".devcontainer.json" file')
def step_dcwire_fs_with_root_level_devcontainer_json(ctx):
tmpdir = tempfile.mkdtemp(prefix="dcwire-fs-rootdc-")
_create_devcontainer_dir(tmpdir, ".devcontainer.json")
ctx.dcwire_handler = FsDirectoryHandler()
ctx.dcwire_resource = _make_fs_resource(tmpdir)
ctx.dcwire_result = None
ctx.dcwire_error = None
ctx.dcwire_last_dc_child = None
# ---------------------------------------------------------------------------
# WHEN steps
# ---------------------------------------------------------------------------
@when("dcwire I call discover_children on the git-checkout resource")
def step_dcwire_discover_git(ctx):
try:
ctx.dcwire_result = ctx.dcwire_handler.discover_children(
resource=ctx.dcwire_resource
)
except Exception as exc:
ctx.dcwire_error = exc
@when("dcwire I call discover_children on the fs-directory resource")
def step_dcwire_discover_fs(ctx):
try:
ctx.dcwire_result = ctx.dcwire_handler.discover_children(
resource=ctx.dcwire_resource
)
except Exception as exc:
ctx.dcwire_error = exc
# ---------------------------------------------------------------------------
# THEN steps
# ---------------------------------------------------------------------------
@then('dcwire the children include a "devcontainer-instance" resource named "{name}"')
def step_dcwire_has_devcontainer_child(ctx, name):
assert ctx.dcwire_error is None, f"Unexpected error: {ctx.dcwire_error}"
assert ctx.dcwire_result is not None, "Expected a list of children"
dc_children = [
r
for r in ctx.dcwire_result
if r.resource_type_name == "devcontainer-instance" and r.name == name
]
assert len(dc_children) == 1, (
f"Expected exactly one devcontainer-instance named '{name}', "
f"got {[r.name for r in ctx.dcwire_result if r.resource_type_name == 'devcontainer-instance']}"
)
ctx.dcwire_last_dc_child = dc_children[0]
@then('dcwire the children include a "fs-directory" resource named "{name}"')
def step_dcwire_has_fs_child(ctx, name):
assert ctx.dcwire_error is None, f"Unexpected error: {ctx.dcwire_error}"
assert ctx.dcwire_result is not None, "Expected a list of children"
fs_children = [
r
for r in ctx.dcwire_result
if r.resource_type_name == "fs-directory" and r.name == name
]
assert len(fs_children) == 1, (
f"Expected exactly one fs-directory named '{name}', "
f"got {[r.name for r in ctx.dcwire_result if r.resource_type_name == 'fs-directory']}"
)
@then('dcwire the devcontainer child has provisioning_state "{state}"')
def step_dcwire_has_provisioning_state(ctx, state):
assert ctx.dcwire_last_dc_child is not None, "No devcontainer child captured"
props = ctx.dcwire_last_dc_child.properties or {}
actual = props.get("provisioning_state")
assert actual == state, f"Expected provisioning_state='{state}', got '{actual}'"
@then("dcwire the devcontainer child has devcontainer_json_path set")
def step_dcwire_has_json_path(ctx):
assert ctx.dcwire_last_dc_child is not None, "No devcontainer child captured"
props = ctx.dcwire_last_dc_child.properties or {}
path_val = props.get("devcontainer_json_path")
assert path_val, f"Expected devcontainer_json_path to be set, got {path_val!r}"
assert "devcontainer.json" in path_val, (
f"Expected devcontainer_json_path to contain 'devcontainer.json', got {path_val!r}"
)
@then('dcwire the devcontainer child has config_name "{config_name}"')
def step_dcwire_has_config_name(ctx, config_name):
assert ctx.dcwire_last_dc_child is not None, "No devcontainer child captured"
props = ctx.dcwire_last_dc_child.properties or {}
actual = props.get("config_name")
assert actual == config_name, (
f"Expected config_name='{config_name}', got '{actual}'"
)
@then("dcwire no devcontainer-instance children are present")
def step_dcwire_no_devcontainer_children(ctx):
assert ctx.dcwire_error is None, f"Unexpected error: {ctx.dcwire_error}"
assert ctx.dcwire_result is not None, "Expected a list of children"
dc_children = [
r for r in ctx.dcwire_result if r.resource_type_name == "devcontainer-instance"
]
assert len(dc_children) == 0, (
f"Expected no devcontainer-instance children, got {[r.name for r in dc_children]}"
)
+1 -1
View File
@@ -755,7 +755,7 @@ def step_try_create_plan_empty_desc(context: Context) -> None:
context.pydantic_error = exc
@then("an Edge Case Pydantic validation error should be raised")
@then("a Pydantic validation error should be raised")
def step_check_pydantic_error(context: Context) -> None:
assert context.pydantic_error is not None, "Expected a Pydantic validation error"
@@ -171,7 +171,7 @@ def step_attempt_rollback(context):
)
@then("the executor rollback result should be False")
@then("the rollback result should be False")
def step_verify_rollback_false(context):
"""Verify the rollback result is False."""
assert context.rollback_result is False
+1 -1
View File
@@ -331,7 +331,7 @@ def step_json_contains(context: Context, text: str) -> None:
assert text in context.pe_json_output, f"Expected '{text}' in JSON output"
@then("the plan explain json output should be valid")
@then("the json output should be valid json")
def step_json_valid(context: Context) -> None:
parsed = json.loads(context.pe_json_output)
assert isinstance(parsed, dict), "Expected a JSON object"
+4 -4
View File
@@ -184,7 +184,7 @@ def step_create_plan_with_description(context: Context, description: str) -> Non
context.plan = _default_plan(description=description)
@given("I create a PlanModel in strategize phase")
@given("I create a plan in strategize phase")
def step_create_plan_strategize_phase(context: Context) -> None:
"""Create a plan in STRATEGIZE phase."""
context.plan = _default_plan(
@@ -204,7 +204,7 @@ def step_create_plan_strategize_with_action_state(context: Context) -> None:
)
@given("I create a PlanModel in strategize phase with errored state")
@given("I create a plan in strategize phase with errored state")
def step_create_plan_strategize_errored(context: Context) -> None:
"""Create a plan in STRATEGIZE phase with ERRORED state."""
context.plan = _default_plan(
@@ -214,7 +214,7 @@ def step_create_plan_strategize_errored(context: Context) -> None:
)
@given("I create a PlanModel in strategize phase with processing state")
@given("I create a plan in strategize phase with processing state")
def step_create_plan_strategize_processing(context: Context) -> None:
"""Create a plan in STRATEGIZE phase with PROCESSING state."""
context.plan = _default_plan(
@@ -268,7 +268,7 @@ def step_create_plan_action_available(context: Context) -> None:
)
@given("I create a PlanModel in strategize phase with cancelled state")
@given("I create a plan in strategize phase with cancelled state")
def step_create_plan_strategize_cancelled(context: Context) -> None:
"""Create a plan in STRATEGIZE phase with CANCELLED processing state."""
context.plan = _default_plan(
+1 -1
View File
@@ -492,7 +492,7 @@ def step_pr_remove_link_by_id(context: Any, link_id: str) -> None:
context.pr_remove_result = context.pr_link_repo.remove_link(link_id)
@then("the project repo remove result should be False")
@then("the remove result should be False")
def step_pr_remove_false(context: Any) -> None:
assert context.pr_remove_result is False
+1 -1
View File
@@ -185,7 +185,7 @@ def step_settings_with_overrides(context: Any, value: int) -> None:
os.environ.pop("CLEVERAGENTS_RETRY_SERVICE_OVERRIDES", None)
@when("I create a ServiceRetryWiring from those retry Settings")
@when("I create a ServiceRetryWiring from those Settings")
def step_create_wiring_from_settings(context: Any) -> None:
from cleveragents.application.services.service_retry_wiring import (
ServiceRetryWiring,
+1 -1
View File
@@ -447,7 +447,7 @@ def session_model_check_export_messages_count(context: Context) -> None:
# ---------------------------------------------------------------------------
@when("I get the session model CLI dict")
@when("I get the session CLI dict")
def session_model_get_cli_dict(context: Context) -> None:
"""Get the CLI dict for the session."""
context.session_cli_dict = context.session_model.as_cli_dict()
@@ -1,150 +0,0 @@
"""Step definitions for tdd_session_create_suppress_exception.feature.
This test captures bug #10414: ``session create`` in
``src/cleveragents/cli/commands/session.py`` used
``contextlib.suppress(Exception)`` to silently discard ALL exceptions raised
by ``_facade_dispatch()`` without any logging. Programming errors,
unexpected failures, and infrastructure issues in the facade dispatch were
completely invisible.
The fix replaces the suppress block with a ``try/except Exception`` that
calls ``_log.warning(..., exc_info=True)`` so that the exception is
recorded but the session creation remains non-fatal.
"""
from __future__ import annotations
from datetime import datetime
import logging
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands import session as session_mod
from cleveragents.cli.commands.session import app as session_app
from cleveragents.domain.models.core.session import Session
# A distinctive error message to confirm the right exception was raised.
_DISPATCH_ERROR_MESSAGE = "facade dispatch failed for tdd_10414"
def _make_mock_session() -> MagicMock:
"""Build a minimal mock Session object sufficient for the create command."""
mock_session = MagicMock(spec=Session)
mock_session.session_id = "01JTEST000000000000000000000"
mock_session.actor_name = None
mock_session.namespace = "default"
mock_session.message_count = 0
mock_session.created_at = datetime(2026, 1, 1, 0, 0, 0)
mock_session.updated_at = datetime(2026, 1, 1, 0, 0, 0)
return mock_session
@given("a session create command with a mocked session service")
def step_given_mocked_session_service(context: Context) -> None:
"""Set up a CLI runner with a mocked session service.
The mock service returns a valid session so that the create command
proceeds past the service call and reaches the ``_facade_dispatch``
try/except block.
"""
context.runner = CliRunner()
mock_service = MagicMock()
mock_service.create.return_value = _make_mock_session()
# Patch the module-level service so the CLI uses our mock.
context._orig_service = session_mod._service
session_mod._service = mock_service
def _cleanup() -> None:
session_mod._service = context._orig_service
context.add_cleanup(_cleanup)
@given("the facade dispatch is patched to raise a RuntimeError")
def step_given_facade_dispatch_raises(context: Context) -> None:
"""Patch ``_facade_dispatch`` so it always raises a RuntimeError.
This simulates a real failure in the facade layer (e.g. the A2A
bootstrap is unavailable) and exercises the ``try/except`` block in
the ``create`` command.
"""
context._facade_patcher = patch(
"cleveragents.cli.commands.session._facade_dispatch",
side_effect=RuntimeError(_DISPATCH_ERROR_MESSAGE),
)
context._facade_patcher.start()
def _cleanup() -> None:
context._facade_patcher.stop()
context.add_cleanup(_cleanup)
@when("I invoke the session create command via the CLI runner")
def step_when_invoke_create(context: Context) -> None:
"""Invoke ``session create`` and capture log records during the call."""
# Capture WARNING-level log records from the session module logger.
session_logger = logging.getLogger("cleveragents.cli.commands.session")
handler = _CapturingHandler()
handler.setLevel(logging.WARNING)
session_logger.addHandler(handler)
session_logger.setLevel(logging.WARNING)
try:
context.result = context.runner.invoke(session_app, ["create"])
finally:
session_logger.removeHandler(handler)
context.captured_log_records = handler.records
class _CapturingHandler(logging.Handler):
"""A logging handler that stores all emitted records in a list."""
def __init__(self) -> None:
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
@then("a WARNING log entry should have been emitted for the facade dispatch failure")
def step_then_warning_logged(context: Context) -> None:
"""Assert that a WARNING log entry was emitted when the facade dispatch raised.
The fix replaces ``contextlib.suppress(Exception)`` with a
``try/except Exception`` block that calls ``_log.warning(..., exc_info=True)``.
This assertion verifies the fix is in place.
"""
# The session create command should still succeed (non-fatal).
assert context.result.exit_code == 0, (
f"Expected session create to exit 0 even when facade dispatch fails, "
f"but got exit code {context.result.exit_code}.\n"
f"Output:\n{context.result.output}"
)
# Assert that a WARNING log record was emitted.
warning_records = [
r for r in context.captured_log_records if r.levelno >= logging.WARNING
]
assert warning_records, (
f"Bug #10414: No WARNING log record was emitted when _facade_dispatch() "
f"raised a RuntimeError inside the try/except block. "
f"Captured records: {context.captured_log_records}"
)
# Assert the log record references the facade dispatch failure.
all_messages = " ".join(r.getMessage() for r in warning_records)
assert _DISPATCH_ERROR_MESSAGE in all_messages or any(
r.exc_info is not None for r in warning_records
), (
f"Bug #10414: WARNING log record was found but did not contain the "
f"expected error message {_DISPATCH_ERROR_MESSAGE!r} or exc_info. "
f"Log messages: {all_messages!r}"
)
@@ -25,6 +25,7 @@ Feature: TDD Issue #988 — ReactiveEventBus.emit() swallows exception details
When I emit an event that triggers the failing handler
Then the warning log should contain the exception message text
@tdd_expected_fail
Scenario: Bug #988 — emit() logs traceback via exc_info when handler raises
Given a ReactiveEventBus with a handler that raises a ValueError
When I emit an event that triggers the failing handler
@@ -1,25 +0,0 @@
@tdd_issue @tdd_issue_10414
Feature: TDD Issue #10414 — session create silently suppresses facade dispatch exceptions without logging
As a developer debugging a session creation failure
I want exceptions from _facade_dispatch() to be logged at WARNING level
So that I can diagnose why the facade layer failed without silent data loss
The ``session create`` command in
``src/cleveragents/cli/commands/session.py`` previously used
``contextlib.suppress(Exception)`` to silently discard ALL exceptions
raised by ``_facade_dispatch()``. There was no logging call before or
after the suppress block, making it impossible to diagnose failures in
the facade layer.
The fix replaces the suppress block with a ``try/except Exception`` that
calls ``_log.warning(..., exc_info=True)`` so that the exception is
recorded but the session creation remains non-fatal.
See CONTRIBUTING.md > Bug Fix Workflow > TDD Issue Test Tags.
@tdd_issue @tdd_issue_10414
Scenario: Bug #10414 — session create logs a warning when facade dispatch raises
Given a session create command with a mocked session service
And the facade dispatch is patched to raise a RuntimeError
When I invoke the session create command via the CLI runner
Then a WARNING log entry should have been emitted for the facade dispatch failure
+4 -2
View File
@@ -23,7 +23,8 @@ TDD Resource Add Succeeds Without Explicit Init
... The helper exits 0 with a sentinel when the command
... succeeds (bug is fixed), and exits 1 when the bug is
... present (OperationalError).
[Tags] tdd_issue tdd_issue_1023 tdd_issue_4317 tdd_expected_fail
[Tags] tdd_issue tdd_issue_1023 tdd_issue tdd_issue_4317 tdd_expected_fail
${result}= Run Process ${PYTHON} ${HELPER} resource-add-no-init cwd=${WORKSPACE} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
@@ -37,7 +38,8 @@ TDD Project Create Succeeds Without Explicit Init
... The helper exits 0 with a sentinel when the command
... succeeds (bug is fixed), and exits 1 when the bug is
... present (OperationalError).
[Tags] tdd_issue tdd_issue_1023 tdd_issue_4317 tdd_expected_fail
[Tags] tdd_issue tdd_issue_1023 tdd_issue tdd_issue_4317 tdd_expected_fail
${result}= Run Process ${PYTHON} ${HELPER} project-create-no-init cwd=${WORKSPACE} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
+466
View File
@@ -0,0 +1,466 @@
"""ACMS context CLI commands.
Implements ``agents acms context list`` and ``agents acms context add`` to
interact with the Application Context Management System (ACMS) tier-based
context pipeline.
Commands:
- ``agents acms context list`` - List all ACMS context fragments across
hot/warm/cold tiers for a project, showing fragment metadata and tier metrics.
- ``agents acms context add`` - Add resource files or directories to the
ACMS context tier store (hydrate the ContextTierService).
Based on the ACMS architecture (context_tier_hydrator, ContextTierService)
and the hot/warm/cold tier fragment lifecycle.
"""
from __future__ import annotations
import os
import subprocess
from datetime import UTC
from pathlib import Path
from typing import Annotated, Any
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.domain.models.acms.tiers import ContextTier, TieredFragment
app = typer.Typer(help="Manage ACMS context (tier-based fragment storage)")
console = Console()
err_console = Console(stderr=True)
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
_MAX_FILE_BYTES = 256 * 1024
_MAX_TOTAL_BYTES = 10 * 1024 * 1024
_SKIP_DIRS = frozenset(
{".git", ".hg", ".svn", "__pycache__", "node_modules",
".venv", "venv", ".nox", ".tox", ".mypy_cache",
".pytest_cache", ".ruff_cache", "dist", "build", ".eggs", ".cleveragents"}
)
_BINARY_EXTS = frozenset(
{".pyc", ".pyo", ".so", ".o", ".a", ".dll", ".exe",
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".pdf",
".zip", ".tar", ".gz", ".bz2", ".xz", ".whl", ".egg",
".db", ".sqlite", ".sqlite3"}
)
def _format_size(size_bytes: int) -> str:
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes / (1024 * 1024):.1f} MB"
def _resolve_project_name(project_arg: str | None) -> Any:
from cleveragents.application.container import get_container
from cleveragents.application.services.project_service import ProjectService
container = get_container()
project_service: ProjectService = container.project_service()
if project_arg:
ns_repo = container.namespaced_project_repo()
try:
project = ns_repo.get(project_arg)
return project
except Exception as exc:
err_console.print(f"[red]Project not found:[/red] {project_arg}")
raise typer.Exit(1) from exc
project = project_service.get_current_project()
if not project:
err_console.print("[red]Error:[/red] No active project. Run 'cleveragents init' first.")
raise typer.Exit(1)
return project
@app.command("list")
def acms_context_list(
project: Annotated[
str | None,
typer.Option("--project", "-p", help="Project namespaced name (defaults to current project)"),
] = None,
tier: Annotated[
ContextTier | None,
typer.Option("--tier", "-t", help="Filter by specific tier: hot, warm, or cold (default: all)"),
] = None,
project_filter: Annotated[
list[str] | None,
typer.Option("--project-filter", "focus_projects", help="Project names to scope fragments from (repeatable)"),
] = None,
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
) -> None:
"""List ACMS context fragments across hot/warm/cold tiers.
Displays all managed context fragments with their tier placement,
token counts, access frequency, and source resource information.
Examples::
# List all fragments for the current project
agents acms context list
# List only hot-tier fragments
agents acms context list --tier hot
# List fragments for a specific project by name
agents acms context list --project local/my-project
# Export as JSON for scripting
agents acms context list --format json
"""
from cleveragents.application.container import get_container
try:
container = get_container()
tier_service = container.context_tier_service()
if project_filter:
fragments = tier_service.get_scoped_view(project_filter)
else:
proj = _resolve_project_name(project)
project_name = getattr(proj, "namespaced_name", str(proj))
fragments = tier_service.get_scoped_view([project_name])
if tier is not None:
fragments = [f for f in fragments if f.tier == tier]
metrics = tier_service.get_metrics()
budget = tier_service.budget
target_project = (
str(project_filter) if project_filter else getattr(proj, "namespaced_name", str(proj))
)
result_data: dict[str, Any] = {
"project": target_project,
"tier_filter": tier.value if tier else "all",
"metrics": {
"hot_count": metrics.hot_count,
"warm_count": metrics.warm_count,
"cold_count": metrics.cold_count,
"total_fragments": metrics.total_fragments,
"hot_hit_rate": f"{metrics.hot_hit_rate:.2%}",
"budget": {
"max_tokens_hot": budget.max_tokens_hot,
"max_decisions_warm": budget.max_decisions_warm,
"max_decisions_cold": budget.max_decisions_cold,
},
},
"fragments": [
{
"fragment_id": f.fragment_id,
"tier": f.tier.value,
"resource_id": f.resource_id or "",
"token_count": f.token_count,
"access_count": f.access_count,
"last_accessed": f.last_accessed.isoformat(),
"metadata": f.metadata,
}
for f in fragments
],
}
if fmt.lower() == OutputFormat.RICH:
_render_list_rich(fragments, metrics, budget, tier)
elif fmt.lower() == "json":
import json as _json_mod
console.print(_json_mod.dumps(result_data, indent=2))
else:
console.print(format_output(result_data, fmt))
except Exception as exc:
err_console.print(f"[red]Error listing ACMS context:[/red] {exc}")
raise typer.Exit(1) from exc
def _render_list_rich(
fragments: list[TieredFragment],
metrics: "TierMetrics", # noqa: F821
budget: "TierBudget", # noqa: F821
tier_filter: ContextTier | None,
) -> None:
"""Render the ACMS context list using rich terminal output."""
tier_label = tier_filter.value if tier_filter else "all"
summary_lines: list[str] = []
summary_lines.append(f"[bold]Total fragments:[/bold] {metrics.total_fragments}")
summary_lines.append(
f"[bold]Hot:[/bold] {metrics.hot_count} "
f"[bold]Warm:[/bold] {metrics.warm_count} "
f"[bold]Cold:[/bold] {metrics.cold_count}"
)
summary_lines.append(f"[bold]Budget (hot tokens):[/bold] {budget.max_tokens_hot:,}")
summary_lines.append(
f"[bold]Hot hit rate:[/bold] "
f"{metrics.hot_hit_rate:.2%} ({metrics.hot_hit_count} hits / "
f"{metrics.hot_hit_count + metrics.hot_miss_count} accesses)"
)
total_tokens_in_fragments = sum(f.token_count for f in fragments)
pct_of_budget = (
(total_tokens_in_fragments / budget.max_tokens_hot * 100) if budget.max_tokens_hot > 0 else 0
)
summary_lines.append(f"[bold]Tokens in view:[/bold] {total_tokens_in_fragments:,} ({pct_of_budget:.1%} of hot budget)")
console.print(Panel("\n".join(summary_lines), title=f"ACMS Context Fragments ({tier_label} tier)", expand=False))
if not fragments:
console.print("[yellow]No context fragments found.[/yellow]")
console.print("Use 'agents acms context add <path>' to add files.")
return
table = Table(title=f"Fragments ({len(fragments)})")
table.add_column("#", style="dim", justify="right", width=4)
table.add_column("Tier", width=6)
table.add_column("Resource / ID", overflow="fold")
table.add_column("Tokens", justify="right", width=12)
table.add_column("Accesses", justify="right", width=8)
table.add_column("Last Accessed", width=20)
tier_order = {ContextTier.HOT: 0, ContextTier.WARM: 1, ContextTier.COLD: 2}
sorted_frags = sorted(fragments, key=lambda f: (tier_order.get(f.tier), -f.access_count))
for idx, frag in enumerate(sorted_frags, 1):
tier_style = {"hot": "green", "warm": "yellow", "cold": "blue"}[frag.tier.value]
short_id = (frag.fragment_id[:50] + "..." if len(frag.fragment_id) > 50 else frag.fragment_id)
table.add_row(
str(idx),
f"[{tier_style}]{frag.tier.value}[/{tier_style}]",
short_id,
f"{frag.token_count:,}",
str(frag.access_count),
frag.last_accessed.strftime("%Y-%m-%d %H:%M"),
)
console.print(table)
@app.command("add")
def acms_context_add(
paths: Annotated[list[str], typer.Argument(help="Paths (files or directories) to add to ACMS context")],
recursive: Annotated[bool, typer.Option("-r", "--recursive", help="Add directories recursively")] = True,
tier: Annotated[ContextTier, typer.Option("--tier", "-t", help="Target tier (default: hot)")] = ContextTier.HOT,
project: Annotated[str | None, typer.Option("--project", "-p", help="Project namespaced name for scoping")] = None,
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
) -> None:
"""Add resource files to the ACMS context tier store.
Reads files from disk and stores them as TieredFragment entries in
the ContextTierService under the specified project scope and target
tier. Binary and large files are automatically skipped.
Examples::
# Add current directory to hot tier (current project)
agents acms context add .
# Add specific files/dirs with recursive traversal
agents acms context add src/ docs/
# Add to warm tier for a named project
agents acms context add ./project-files --tier warm -p local/my-project
# Non-recursive (single file only)
agents acms context add README.md -r false
"""
from cleveragents.application.container import get_container
try:
container = get_container()
tier_service = container.context_tier_service()
proj = _resolve_project_name(project)
project_name = getattr(proj, "namespaced_name", str(proj))
from cleveragents.application.services.resource_registry_service import ResourceRegistryService
resource_reg: ResourceRegistryService = container.resource_registry()
resources_for_project = []
try:
if hasattr(proj, "project_id"):
resources_for_project = resource_reg.list_resources(project=proj.project_id)
except Exception:
pass
if resources_for_project and len(resources_for_project) > 0:
res_obj = resources_for_project[0]
resource_id = str(res_obj.resource_id)
resource_location = getattr(res_obj, "location", None) or getattr(proj, "location", None) or "."
else:
resource_id = f"adhoc/{project_name}"
resource_location = str(Path.cwd())
added_frags: list[TieredFragment] = []
already_in_context: list[str] = []
skipped_files: list[str] = []
total_bytes = 0
for path_str in paths:
path = Path(path_str).resolve()
if not path.exists():
err_console.print(f"[red]Path does not exist:[/red] {path}")
continue
files_to_add: list[Path] = []
if path.is_file():
files_to_add = [path]
elif path.is_dir() and recursive:
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS and not d.startswith(".")]
for fname in filenames:
if fname.startswith("."):
continue
ext = os.path.splitext(fname)[1].lower()
if ext in _BINARY_EXTS:
skipped_files.append(str(Path(dirpath) / fname))
continue
files_to_add.append(Path(dirpath) / fname)
elif path.is_dir() and not recursive:
raise typer.BadParameter(f"Directory requires --recursive flag. Use 'add <path>' for individual files only.")
for file_path in files_to_add:
try:
size = file_path.stat().st_size
except OSError:
continue
if size > _MAX_FILE_BYTES:
skipped_files.append(str(file_path))
continue
if total_bytes + size > _MAX_TOTAL_BYTES:
break
content = ""
try:
content = file_path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
skipped_files.append(str(file_path))
continue
try:
res_root = Path(resource_location) if isinstance(resource_location, str) else resource_location
rel_path = str(file_path.relative_to(res_root))
except (ValueError, TypeError):
rel_path = str(file_path)
fragment_id = f"{resource_id}:{rel_path}"
existing = tier_service._find_fragment(fragment_id)
if existing is not None:
already_in_context.append(rel_path)
continue
fragment = TieredFragment(
fragment_id=fragment_id,
content=content,
tier=tier,
resource_id=resource_id,
project_name=project_name,
token_count=len(content) // 4,
metadata={
"path": rel_path,
"detail_depth": "1",
"relevance_score": "0.5",
"source": str(file_path),
},
)
tier_service.store(fragment)
added_frags.append(fragment)
total_bytes += size
result_data: dict[str, Any] = {
"project": project_name,
"resource_id": resource_id,
"tier": tier.value,
"added_count": len(added_frags),
"already_exists_count": len(already_in_context),
"skipped_count": len(skipped_files),
"total_bytes": total_bytes,
"added_files": [f.fragment_id for f in added_frags],
"already_in_context": already_in_context,
"skipped_files": skipped_files,
}
if fmt.lower() == OutputFormat.RICH:
messages: list[tuple[str, str]] = []
if added_frags:
shown = min(len(added_frags), 10)
file_list = "\n".join(f" {f.fragment_id}" for f in added_frags[:shown])
extra = "" if len(added_frags) <= 10 else f"\n ... and {len(added_frags) - 10} more"
messages.append(("Added", f"[green]Added [bold]{len(added_frags)}[/bold] file(s) to [{tier.value}] tier ({_format_size(total_bytes)})\n{file_list}{extra}"))
if already_in_context:
shown = min(len(already_in_context), 10)
messages.append(("Already in context", f"[yellow]{len(already_in_context)}[/yellow] file(s) already stored:\n" + "\n".join(f" - {f}" for f in already_in_context[:shown]) + (f"\n ... and {len(already_in_context) - 10} more" if len(already_in_context) > 10 else "")))
if skipped_files:
messages.append(("Skipped", f"[dim]{len(skipped_files)} file(s) skipped (binary or oversized)[/dim]"))
for title, body in messages:
console.print(Panel(body, title=title, expand=False))
else:
console.print(format_output(result_data, fmt))
except Exception as exc:
err_console.print(f"[red]Error adding to ACMS context:[/red] {exc}")
raise typer.Exit(1) from exc
@app.command("reset")
def acms_context_reset(
project: Annotated[str | None, typer.Option("--project", "-p", help="Project namespaced name (defaults to current project)")] = None,
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt")] = False,
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
) -> None:
"""Reset (clear) all ACMS context fragments for a project."""
from cleveragents.application.container import get_container
from cleveragents.domain.models.acms.tiers import ContextTier
try:
container = get_container()
tier_service = container.context_tier_service()
proj = _resolve_project_name(project)
project_name = getattr(proj, "namespaced_name", str(proj))
all_frags = tier_service.get_scoped_view([project_name])
fragment_ids_to_remove = [f.fragment_id for f in all_frags]
if not fragment_ids_to_remove:
console.print("[yellow]No fragments to reset.[/yellow]")
return
if not yes:
typer.echo(f"Found {len(fragment_ids_to_remove)} fragment(s) for project '{project_name}'.")
if not typer.confirm("Reset (clear) all fragments?"):
typer.echo("Reset cancelled.")
return
stores = {ContextTier.HOT: tier_service._hot, ContextTier.WARM: tier_service._warm, ContextTier.COLD: tier_service._cold}
removed_count = 0
for store in stores.values():
to_remove = [fid for fid, frag in store.items() if frag.project_name == project_name]
for fid in to_remove:
del store[fid]
removed_count += 1
console.print(Panel(
f"[green]Reset ACMS context for project '[bold]{project_name}[/bold]'\n"
f"[bold]Removed:[/bold] {removed_count} fragment(s)",
title="ACMS Context Reset",
expand=False,
))
except Exception as exc:
err_console.print(f"[red]Error resetting ACMS context:[/red] {exc}")
raise typer.Exit(1) from exc
+10 -20
View File
@@ -534,13 +534,12 @@ def _print_role_warnings(config_blob: dict[str, Any]) -> None:
@app.command()
def add(
name: Annotated[
str | None,
str,
typer.Argument(
help="Namespaced actor name (e.g. local/my-actor). "
"If omitted, the name is derived from the config file.",
help="Namespaced actor name (e.g. local/my-actor)",
metavar="NAME",
),
] = None,
],
config: Annotated[
Path | None,
typer.Option("--config", "-c", help="Path to JSON/YAML actor config"),
@@ -570,19 +569,19 @@ def add(
) -> None:
"""Add a new actor configuration.
The YAML/JSON configuration file specified with ``--config`` supplies the
actor settings. The actor's registered name is taken from the config file's
``name`` field, unless overridden by the positional ``NAME`` argument.
The actor name is provided as a required positional argument. The YAML/JSON
configuration file specified with ``--config`` supplies the actor settings.
The positional ``NAME`` takes precedence over any ``name`` field in the
config file.
Signature:
``agents actor add [--config|-c <FILE>] [<NAME>] [--update] [--unsafe]
``agents actor add <NAME> --config <FILE> [--update] [--unsafe]
[--set-default] [--option key=value] [--format FORMAT]``
Examples:
agents actor add --config ./actors/my-actor.yaml
agents actor add local/my-actor --config ./actors/my-actor.yaml
agents actor add --config ./actors/my-actor.yaml --update
agents actor add --config actor.yaml --format json
agents actor add local/my-actor --config ./actors/my-actor.yaml --update
agents actor add local/my-actor --config actor.yaml --format json
"""
service, registry = _get_services()
option_overrides = _parse_option_overrides(option)
@@ -598,15 +597,6 @@ def add(
assert loaded is not None, "unreachable: config is not None"
yaml_text, config_blob = loaded
# Derive actor name from config when not provided as argument.
if name is None:
name = config_blob.get("name")
if not name:
raise typer.BadParameter(
"Actor name is required. Provide it as a positional argument "
"or as a 'name' field in the config file."
)
# Validate v3 config via ActorConfigSchema if detected.
# This ensures v3 actors are fully validated (cycle detection, required
# fields, enum values) before the registry stores them.
+3 -7
View File
@@ -213,17 +213,13 @@ def create(
# Notify the facade layer for A2A protocol bookkeeping.
# Pass session_id so the facade handler acknowledges the already-
# persisted session instead of creating a duplicate (#1141).
try:
import contextlib
with contextlib.suppress(Exception):
_facade_dispatch(
"session.create",
{"actor_name": actor or "", "session_id": session.session_id},
)
except Exception as _exc:
_log.warning(
"session_create_facade_dispatch_failed",
extra={"session_id": session.session_id, "error": str(_exc)},
exc_info=True,
)
data = _session_summary_dict(session)
+8
View File
@@ -98,6 +98,7 @@ def _register_subcommands() -> None:
tui,
validation,
)
from cleveragents.cli.commands.acms import app as acms_app
from cleveragents.cli.commands.auto_debug import app as auto_debug_app
from cleveragents.cli.commands.db import app as db_app
from cleveragents.cli.commands.repl import _repl_app
@@ -228,6 +229,11 @@ def _register_subcommands() -> None:
name="repo",
help="Repository indexing management",
)
app.add_typer(
acms_app,
name="acms",
help="Application Context Management System (tier-based context)",
)
_subcommands_registered = True
@@ -684,6 +690,7 @@ def main(args: list[str] | None = None) -> int:
"tui", # Textual TUI
"server", # Server connection management
"repo", # Repository indexing management
"acms", # ACMS - Application Context Management System
"apply", # Shortcut for plan apply
"context-load", # Shortcut for context add
"context-add", # Shortcut
@@ -716,6 +723,7 @@ def main(args: list[str] | None = None) -> int:
"apply",
"context-load",
"context-add",
"acms",
}
)
if hasattr(app, "add_typer") and not (
@@ -135,7 +135,6 @@ class ReactiveEventBus:
handler=getattr(handler, "__qualname__", repr(handler)),
error_type=type(exc).__name__,
error=str(exc),
exc_info=True,
)
def subscribe(
@@ -37,7 +37,6 @@ from cleveragents.resource.handlers._base import (
EMPTY_CONTENT_HASH,
BaseResourceHandler,
)
from cleveragents.resource.handlers.discovery import discover_devcontainers
from cleveragents.resource.handlers.protocol import (
CheckpointResult,
Content,
@@ -234,13 +233,10 @@ class FsDirectoryHandler(BaseResourceHandler):
)
def discover_children(self, *, resource: Resource) -> list[Resource]:
"""Discover subdirectories and devcontainer instances as child resources.
"""Discover subdirectories as child resources.
Each immediate subdirectory becomes a child ``fs-directory``
resource. Additionally, any ``.devcontainer/`` configurations
found at the resource location are registered as
``devcontainer-instance`` child resources with
``provisioning_state: discovered``.
resource.
Args:
resource: The parent fs-directory resource.
@@ -265,28 +261,6 @@ class FsDirectoryHandler(BaseResourceHandler):
)
children.append(child)
# Wire devcontainer auto-discovery (issue #4740)
dc_results = discover_devcontainers(location, "fs-directory")
for dc_result in dc_results:
config_name = dc_result.config_name or "default"
dc_child = Resource(
resource_id=self._derive_child_id(
resource.resource_id, f"devcontainer-{config_name}"
),
name=f"devcontainer-{config_name}",
resource_type_name="devcontainer-instance",
classification=resource.classification,
description=f"Devcontainer at {dc_result.config_path}",
location=location,
parents=[resource.resource_id],
properties={
"devcontainer_json_path": str(dc_result.config_path),
"config_name": config_name,
"provisioning_state": "discovered",
},
)
children.append(dc_child)
return children
# -- Checkpoint and rollback (issue #836) ------------------------------
@@ -6,18 +6,17 @@ strategy (with fallback to ``copy_on_write``).
Content CRUD operations (issue #827):
- ``read`` -- ``git show HEAD:<path>``
- ``write`` -- atomic file write inside the checkout
- ``delete`` -- ``os.remove`` + ``git rm --cached``
- ``list_children`` -- ``git ls-tree -r --name-only HEAD``
- ``diff`` -- ``git diff --no-index``
- ``discover_children`` -- ``git ls-tree --name-only HEAD`` + devcontainer discovery
- ``read`` ``git show HEAD:<path>``
- ``write`` atomic file write inside the checkout
- ``delete`` ``os.remove`` + ``git rm --cached``
- ``list_children`` ``git ls-tree -r --name-only HEAD``
- ``diff`` ``git diff --no-index``
- ``discover_children`` ``git ls-tree --name-only HEAD``
Based on:
- implementation_plan.md group M1.resource-handlers (L2254-L2271)
- Built-in type definition in resource_registry_service.py L62-98
- Issue #827 -- ResourceHandler CRUD and discovery methods
- Issue #4740 -- Wire discover_devcontainers() into git-checkout handler
- Issue #827 ResourceHandler CRUD and discovery methods
"""
from __future__ import annotations
@@ -37,7 +36,6 @@ from cleveragents.resource.handlers._base import (
EMPTY_CONTENT_HASH,
BaseResourceHandler,
)
from cleveragents.resource.handlers.discovery import discover_devcontainers
from cleveragents.resource.handlers.protocol import (
CheckpointResult,
Content,
@@ -305,20 +303,17 @@ class GitCheckoutHandler(BaseResourceHandler):
)
def discover_children(self, *, resource: Resource) -> list[Resource]:
"""Discover child resources via ``git ls-tree`` and devcontainer scan.
"""Discover child resources via ``git ls-tree``.
Each top-level directory in the repo becomes a child resource
of type ``fs-directory``. Additionally, any ``.devcontainer/``
configurations found at the resource location are registered as
``devcontainer-instance`` child resources with
``provisioning_state: discovered``.
of type ``fs-directory``.
Args:
resource: The parent git-checkout resource.
Returns:
List of child :class:`Resource` objects for top-level
directories and discovered devcontainer instances.
directories.
"""
location = self._require_location(resource)
@@ -351,28 +346,6 @@ class GitCheckoutHandler(BaseResourceHandler):
)
children.append(child)
# Wire devcontainer auto-discovery (issue #4740)
dc_results = discover_devcontainers(location, "git-checkout")
for dc_result in dc_results:
config_name = dc_result.config_name or "default"
dc_child = Resource(
resource_id=self._derive_child_id(
resource.resource_id, f"devcontainer-{config_name}"
),
name=f"devcontainer-{config_name}",
resource_type_name="devcontainer-instance",
classification=resource.classification,
description=f"Devcontainer at {dc_result.config_path}",
location=location,
parents=[resource.resource_id],
properties={
"devcontainer_json_path": str(dc_result.config_path),
"config_name": config_name,
"provisioning_state": "discovered",
},
)
children.append(dc_child)
return children
# -- Checkpoint and rollback (issue #836) ------------------------------