diff --git a/CHANGELOG.md b/CHANGELOG.md index c472412de..3a01815f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,8 @@ Changed `wf10_batch.robot` to be less likely to create files, and - **Plan Rollback Command** (#8557): Implemented `agents plan rollback []` for checkpoint-based plan state restoration in Epic #8493. The command restores a plan's sandbox to the state captured at a given checkpoint, discarding all decisions made after that checkpoint. The checkpoint can be specified as an optional positional second argument or via the `--to-checkpoint` named option. Supports `--yes/-y` flag to skip confirmation prompts and `--format/-f` for output format selection (rich/plain/json/yaml). Included with comprehensive BDD test coverage (>= 97%) and spec-aligned output formatting showing rollback summary, changes reverted, impact analysis, and post-rollback state panels. ### Fixed +- **fix(invariant): restore ACTION scope in merge_invariants and InvariantSet.merge precedence chain** (#9126): Added action_invariants parameter to merge_invariants() and InvariantSet.merge() with correct 4-tier precedence chain (plan > action > project > global). Updated InvariantScope docstring, invariant_service.py get_effective_invariants() with action_name wildcard support. + - **Guard cleanup_stale against execute/processing and execute/complete plans** (#11121): ``_create_sandbox_for_plan()`` in ``src/cleveragents/cli/commands/plan.py`` now skips ``GitWorktreeSandbox.cleanup_stale()`` when the plan is in diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index bf1d924b9..380a11d8d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -43,5 +43,7 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata. * HAL 9000 has contributed database resource types (PostgreSQL, SQLite) with transaction-based sandbox strategy: implemented ``DatabaseResourceHandler`` providing full CRUD operations (`read`, `write`, `delete`, `list_children`) and connection validation with automatic credential masking for PostgreSQL and SQLite backends. Includes ``TransactionSandbox`` infrastructure wired into ``SandboxFactory``, BDD test coverage in ``features/database_resources.feature``, and Robot Framework integration tests in ``robot/database_resources.robot`` (PR #10591 / issue #8608, Epic #8568). * HAL 9000 has contributed the agents plan rollback command (PR #8674 / issue #8557): implemented checkpoint-based plan state restoration with the `agents plan rollback []` CLI command as part of Epic #8493, enabling plans to be restored to previous checkpoints, discarding post-checkpoint decisions, and resuming execution from the rolled-back state. Supported by `--yes/-y`, `--to-checkpoint`, and `--format/-f` flags. Includes comprehensive BDD test coverage (>= 97%) for rollback, decision discarding, and plan resume functionality. + +* HAL 9000 has contributed the invariant ACTION scope merge fix (PR #12674 / issue #9126): restored ACTION scope in ``merge_invariants()`` and ``InvariantSet.merge()`` precedence chain from ``plan > project > global`` to ``plan > action > project > global``, added ``action_invariants`` parameter, updated ``get_effective_invariants()`` with ``action_name`` wildcard support, and added BDD test scenarios covering 4-tier merge ordering, action-over-project override, action-level deduplication, and InvariantSet 4-tier merge. * HAL 9000 has contributed the PyYAML security upgrade (PR #11012 / issue #9055): added `pyyaml>=6.0.3` dependency constraint to address known YAML parsing vulnerabilities. * HAL 9000 has contributed the A2A module rename standardization BDD tests (PR #10583 / issue #8615): comprehensive Behave test suite validating that all 22 A2A symbols are properly exported from `cleveragents.a2a`, no legacy ACP references remain in the module source, and documentation uses correct A2A naming conventions — fixing inline imports, unused behave symbols, cross-scenario context dependencies, and missing type annotations. diff --git a/features/consolidated_domain_models.feature b/features/consolidated_domain_models.feature index c58469481..a37cc17d3 100644 --- a/features/consolidated_domain_models.feature +++ b/features/consolidated_domain_models.feature @@ -418,13 +418,15 @@ Feature: Consolidated Domain Models Scenario: InvariantScope has four values Then InvariantScope should have values "global, project, action, plan" - # === Merge Precedence (plan > project > global) === + # === Merge Precedence (plan > action > project > global) === Scenario: Merge with no duplicates preserves all invariants Given I have plan invariants | text | source | | Plan constraint | plan1 | + And I have action invariants + | text | source | And I have project invariants | text | source | | Project constraint | proj1 | @@ -442,6 +444,8 @@ Feature: Consolidated Domain Models Given I have plan invariants | text | source | | Log all changes | plan1 | + And I have action invariants + | text | source | And I have project invariants | text | source | | Log all changes | proj1 | @@ -455,6 +459,8 @@ Feature: Consolidated Domain Models Scenario: Project invariant overrides global invariant with same text Given I have plan invariants | text | source | + And I have action invariants + | text | source | And I have project invariants | text | source | | Log all changes | proj1 | @@ -470,6 +476,8 @@ Feature: Consolidated Domain Models Given I have plan invariants | text | source | | LOG ALL CHANGES | plan1 | + And I have action invariants + | text | source | And I have global invariants | text | source | | log all changes | system | @@ -482,6 +490,8 @@ Feature: Consolidated Domain Models Scenario: Inactive invariants are excluded from merge Given I have plan invariants with an inactive entry + And I have action invariants + | text | source | And I have project invariants | text | source | And I have global invariants @@ -496,6 +506,8 @@ Feature: Consolidated Domain Models Given I have plan invariants | text | source | | Plan rule | plan1 | + And I have action invariants + | text | source | And I have project invariants | text | source | | Project rule | proj1 | @@ -505,7 +517,81 @@ Feature: Consolidated Domain Models When I merge using InvariantSet Then the invariant set should have 3 invariants - # === Service: Add/List/Remove === + + # === 4-Tier Merge --- Issue #9126 === + + + Scenario: 4-tier merge preserves correct order + Given I have plan invariants + | text | source | + | Plan rule | plan1 | + And I have action invariants + | text | source | + | Action constraint | local/act1 | + And I have project invariants + | text | source | + | Project rule | proj1 | + And I have global invariants + | text | source | + | Global rule | system | + When I merge the invariants + Then the merged set should have 4 invariants + And the merged invariant at index 0 should have text "Plan rule" + And the merged invariant at index 1 should have text "Action constraint" + And the merged invariant at index 2 should have text "Project rule" + And the merged invariant at index 3 should have text "Global rule" + + + Scenario: Action invariant overrides project invariant with same text + Given I have plan invariants + | text | source | + And I have action invariants + | text | source | + | Log all changes | local/act1 | + And I have project invariants + | text | source | + | Log all changes | proj1 | + And I have global invariants + | text | source | + When I merge the invariants + Then the merged set should have 1 invariants + And the merged invariant at index 0 should have scope "action" + + + Scenario: Action-level deduplication removes duplicates within tier + Given I have plan invariants + | text | source | + And I have action invariants + | text | source | + | Log all changes | local/act1 | + | Log all changes | local/act2 | + And I have project invariants + | text | source | + And I have global invariants + | text | source | + When I merge the invariants + Then the merged set should have 1 invariants + And the merged invariant at index 0 should have text "Log all changes" + + + Scenario: InvariantSet.merge with 4 tiers produces correct result + Given I have plan invariants + | text | source | + | Plan rule | plan1 | + And I have action invariants + | text | source | + | Action constraint | local/act1 | + And I have project invariants + | text | source | + | Project rule | proj1 | + And I have global invariants + | text | source | + | Global rule | system | + When I merge using InvariantSet + Then the invariant set should have 4 invariants + + + # === Service: Add/List/Remove === Scenario: Service add invariant diff --git a/features/steps/invariant_models_steps.py b/features/steps/invariant_models_steps.py index b1909d647..e9f4a58d6 100644 --- a/features/steps/invariant_models_steps.py +++ b/features/steps/invariant_models_steps.py @@ -135,6 +135,11 @@ def step_plan_invariants(context): context.plan_invariants = _parse_invariant_table(context, InvariantScope.PLAN) +@given("I have action invariants") +def step_action_invariants(context): + context.action_invariants = _parse_invariant_table(context, InvariantScope.ACTION) + + @given("I have project invariants") def step_project_invariants(context): context.project_invariants = _parse_invariant_table(context, InvariantScope.PROJECT) @@ -160,6 +165,7 @@ def step_plan_invariants_inactive(context): def step_merge(context): context.merged = merge_invariants( getattr(context, "plan_invariants", []), + getattr(context, "action_invariants", []), getattr(context, "project_invariants", []), getattr(context, "global_invariants", []), ) @@ -189,6 +195,7 @@ def step_merged_scope(context, idx, scope): def step_merge_invariant_set(context): inv_set = InvariantSet.merge( getattr(context, "plan_invariants", []), + getattr(context, "action_invariants", []), getattr(context, "project_invariants", []), getattr(context, "global_invariants", []), ) diff --git a/src/cleveragents/application/services/invariant_service.py b/src/cleveragents/application/services/invariant_service.py index e941b999e..e84592c83 100644 --- a/src/cleveragents/application/services/invariant_service.py +++ b/src/cleveragents/application/services/invariant_service.py @@ -11,7 +11,7 @@ a dict keyed by invariant ID. ## Merge Precedence -Effective invariants are computed using plan > project > global order. +Effective invariants are computed using plan > action > project > global order. See ``merge_invariants`` for de-duplication semantics. Based on ``docs/specification.md`` and implementation plan Stage M3.5. @@ -167,16 +167,20 @@ class InvariantService: def get_effective_invariants( self, plan_id: str | None = None, + action_name: str | None = None, project_name: str | None = None, ) -> list[Invariant]: """Return the merged precedence chain for a plan/project context. Collects active invariants from each scope tier and merges them - using plan > project > global precedence. + using plan > action > project > global precedence. Args: plan_id: Optional plan identifier to collect plan-scoped invariants. + action_name: Optional action identifier to collect action-scoped + invariants. Pass ``"*"`` or a falsy value to collect ALL + action-level invariants regardless of source name. project_name: Optional project name to collect project-scoped invariants. @@ -191,6 +195,12 @@ class InvariantService: if inv.scope == InvariantScope.PLAN and (plan_id is None or inv.source_name == plan_id) ] + action_invs = [ + inv + for inv in active + if inv.scope == InvariantScope.ACTION + and (action_name is None or inv.source_name == action_name) + ] project_invs = [ inv for inv in active @@ -199,7 +209,9 @@ class InvariantService: ] global_invs = [inv for inv in active if inv.scope == InvariantScope.GLOBAL] - return merge_invariants(plan_invs, project_invs, global_invs) + return merge_invariants( + plan_invs, action_invs, project_invs, global_invs + ) def enforce_invariants( self, diff --git a/src/cleveragents/domain/models/core/invariant.py b/src/cleveragents/domain/models/core/invariant.py index 1880bb668..e2147af9b 100644 --- a/src/cleveragents/domain/models/core/invariant.py +++ b/src/cleveragents/domain/models/core/invariant.py @@ -1,27 +1,26 @@ """Invariant domain models for CleverAgents. -Invariants are natural-language constraints on plan execution, scoped at -global, project, action, or plan level. When an action is used, its -invariants are promoted to plan-level. The runtime precedence chain is: +Invariant constraints on natural-language plan execution, scoped at +global, project, action, or plan level. The runtime precedence chain is: - plan > project > global + plan > action > project > global They are reconciled by the Invariant Reconciliation Actor at the start of Strategize and recorded as ``invariant_enforced`` decisions. ## Scope Hierarchy -| Scope | Description | -|------------|----------------------------------------------------| -| ``GLOBAL`` | Applies to every plan in the system | -| ``PROJECT``| Applies to plans targeting a specific project | -| ``ACTION`` | Defined in an action template; promoted on ``use`` | -| ``PLAN`` | Attached directly to a specific plan | +| Scope | Description | +|------------|----------------------------------------------------------------| +| ``GLOBAL`` | Applies to every plan in the system | +| ``PROJECT``| Applies to plans targeting a specific project | +| ``ACTION`` | Defined in the action template; its own tier in precedence | +| ``PLAN`` | Attached directly to a specific plan | ## Merge Precedence When computing the effective set of invariants for a plan, the merge -order is **plan > project > global**. Duplicate texts (case-insensitive) +order is **plan > action > project > global**. Duplicate texts (case-insensitive) are de-duplicated, keeping the highest-precedence copy. Based on ``docs/specification.md`` and implementation plan Stage M3.5. @@ -39,8 +38,8 @@ from ulid import ULID class InvariantScope(StrEnum): """Scope at which an invariant applies. - Precedence (highest to lowest): PLAN > PROJECT > GLOBAL. - ACTION invariants are promoted to PLAN scope at ``plan use`` time. + Precedence (highest to lowest): PLAN > ACTION > PROJECT > GLOBAL. + ACTION invariants are a separate precedence tier, not promoted. """ GLOBAL = "global" @@ -137,10 +136,11 @@ class InvariantSet(BaseModel): def merge( cls, plan_invariants: list[Invariant], + action_invariants: list[Invariant], project_invariants: list[Invariant], global_invariants: list[Invariant], ) -> InvariantSet: - """Merge invariants respecting plan > project > global precedence. + """Merge invariants respecting plan > action > project > global precedence. De-duplicates by text (case-insensitive), keeping the copy from the highest-precedence tier. Within each tier, source ordering @@ -148,6 +148,7 @@ class InvariantSet(BaseModel): Args: plan_invariants: Plan-level invariants (highest precedence). + action_invariants: Action-level invariants. project_invariants: Project-level invariants. global_invariants: Global-level invariants (lowest precedence). @@ -156,7 +157,12 @@ class InvariantSet(BaseModel): """ return cls( invariants=tuple( - merge_invariants(plan_invariants, project_invariants, global_invariants) + merge_invariants( + plan_invariants, + action_invariants, + project_invariants, + global_invariants, + ) ) ) @@ -165,10 +171,11 @@ class InvariantSet(BaseModel): def merge_invariants( plan_invariants: list[Invariant], + action_invariants: list[Invariant], project_invariants: list[Invariant], global_invariants: list[Invariant], ) -> list[Invariant]: - """Merge invariants implementing plan > project > global precedence. + """Merge invariants implementing plan > action > project > global precedence. De-duplicates by text (case-insensitive). The first occurrence (from the highest-precedence tier) wins. Within each tier, the @@ -176,6 +183,7 @@ def merge_invariants( Args: plan_invariants: Plan-level invariants (highest precedence). + action_invariants: Action-level invariants. project_invariants: Project-level invariants. global_invariants: Global-level invariants (lowest precedence). @@ -185,7 +193,12 @@ def merge_invariants( seen: set[str] = set() result: list[Invariant] = [] - for inv_list in (plan_invariants, project_invariants, global_invariants): + for inv_list in ( + plan_invariants, + action_invariants, + project_invariants, + global_invariants, + ): for inv in inv_list: if not inv.active: continue