feat(plan): enforce invariants during Strategize phase via Invariant Reconciliation Actor #1154
@@ -2,6 +2,14 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Wired Invariant Reconciliation Actor into the Strategize phase of the
|
||||
plan lifecycle. When `PlanLifecycleService.start_strategize()` is called,
|
||||
invariants from all scopes (global, project, action, plan) are collected,
|
||||
reconciled using plan > project > global precedence, and recorded as
|
||||
`invariant_enforced` decisions in the plan's decision tree. Added
|
||||
`invariant_service` parameter to `PlanLifecycleService` and registered
|
||||
`InvariantService` as a singleton in the DI container. Includes 6 Behave
|
||||
BDD scenarios and 4 Robot integration tests. (#843)
|
||||
- Added TDD bug-capture E2E tests for bug #1028 — ACMS indexing pipeline not
|
||||
wired into CLI. Four Robot Framework E2E tests prove ContextTierService starts
|
||||
empty on every CLI invocation. Tests use ``@tdd_expected_fail`` until the bug
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
Feature: Invariant Enforcement During Strategize Phase
|
||||
As a plan strategizer
|
||||
I want invariants to be automatically enforced when a plan enters Strategize
|
||||
So that the decision tree reflects invariant constraints as first-class decisions
|
||||
|
||||
Background:
|
||||
Given a lifecycle service with invariant enforcement wired
|
||||
|
||||
# === Basic enforcement ===
|
||||
|
||||
Scenario: Invariants are enforced when strategize starts
|
||||
Given an action "local/test-action" with action invariants
|
||||
| text | source |
|
||||
| All tests must pass | action |
|
||||
And a plan created from invariant-action "local/test-action"
|
||||
And a global invariant "Never delete production data" in the invariant service
|
||||
When I begin strategize with invariant enforcement on the plan
|
||||
Then the enforced plan should be in processing state
|
||||
And invariant_enforced decisions should exist in the decision tree
|
||||
And the decision tree should have an invariant_enforced for "All tests must pass"
|
||||
And the decision tree should have an invariant_enforced for "Never delete production data"
|
||||
|
||||
Scenario: Strategize succeeds with no invariants
|
||||
Given an action "local/no-invariants" with no action invariants
|
||||
And a plan created from invariant-action "local/no-invariants"
|
||||
When I begin strategize with invariant enforcement on the plan
|
||||
Then the enforced plan should be in processing state
|
||||
And 0 invariant_enforced decisions should be in the decision tree
|
||||
|
||||
Scenario: Strategize succeeds when invariant service is not available
|
||||
Given a lifecycle service without invariant enforcement wired
|
||||
And an action "local/test-action" created on the bare service
|
||||
And a plan created from bare-service action "local/test-action"
|
||||
When I begin strategize on the bare-service plan
|
||||
Then the bare-service plan should be in processing state
|
||||
|
||||
# === Multi-scope enforcement ===
|
||||
|
||||
Scenario: Invariants from multiple scopes are reconciled during strategize
|
||||
Given an action "local/multi-scope" with action invariants
|
||||
| text | source |
|
||||
| Mock all network calls | action |
|
||||
And a plan created from invariant-action "local/multi-scope"
|
||||
And a global invariant "Backward compatibility required" in the invariant service
|
||||
And a project invariant "Use ORM for queries" in the invariant service for project "local/api-service"
|
||||
And the enforced plan targets project "local/api-service"
|
||||
When I begin strategize with invariant enforcement on the plan
|
||||
Then the enforced plan should be in processing state
|
||||
And invariant_enforced decisions should exist in the decision tree
|
||||
And the invariant_enforced decision count should be at least 3
|
||||
|
||||
# === Conflict resolution during enforcement ===
|
||||
|
||||
Scenario: Duplicate invariants are de-duplicated during enforcement
|
||||
Given an action "local/dedup-action" with action invariants
|
||||
| text | source |
|
||||
| All tests must pass | action |
|
||||
And a plan created from invariant-action "local/dedup-action"
|
||||
And a global invariant "All tests must pass" in the invariant service
|
||||
When I begin strategize with invariant enforcement on the plan
|
||||
Then the enforced plan should be in processing state
|
||||
And the invariant_enforced decision count should be at least 1
|
||||
|
||||
# === Failure resilience ===
|
||||
|
||||
Scenario: Invariant enforcement failure does not block strategize
|
||||
Given an action "local/resilience-test" with action invariants
|
||||
| text | source |
|
||||
| Some constraint | action |
|
||||
And a plan created from invariant-action "local/resilience-test"
|
||||
And the invariant service is rigged to fail
|
||||
When I begin strategize with invariant enforcement on the plan
|
||||
Then the enforced plan should be in processing state
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Step definitions for invariant_enforcement_strategize.feature.
|
||||
|
||||
Tests that the Invariant Reconciliation Actor is invoked during the
|
||||
Strategize phase via ``PlanLifecycleService.start_strategize``, recording
|
||||
``invariant_enforced`` decisions in the decision tree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.application.services.decision_service import DecisionService
|
||||
from cleveragents.application.services.invariant_service import InvariantService
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.domain.models.core.decision import DecisionType
|
||||
from cleveragents.domain.models.core.invariant import InvariantScope
|
||||
from cleveragents.domain.models.core.plan import ProcessingState, ProjectLink
|
||||
|
||||
# ================================================================
|
||||
# Background
|
||||
# ================================================================
|
||||
|
||||
|
||||
@given("a lifecycle service with invariant enforcement wired")
|
||||
def step_lifecycle_with_invariant_enforcement(context: Context) -> None:
|
||||
"""Create a PlanLifecycleService with InvariantService and DecisionService."""
|
||||
settings = Settings()
|
||||
context.inv_enforcement_svc = InvariantService()
|
||||
context.dec_enforcement_svc = DecisionService()
|
||||
context.enforcement_lifecycle = PlanLifecycleService(
|
||||
settings=settings,
|
||||
decision_service=context.dec_enforcement_svc,
|
||||
invariant_service=context.inv_enforcement_svc,
|
||||
)
|
||||
context.error = None
|
||||
|
||||
|
||||
@given("a lifecycle service without invariant enforcement wired")
|
||||
def step_lifecycle_without_invariant_service(context: Context) -> None:
|
||||
"""Create a PlanLifecycleService without InvariantService."""
|
||||
settings = Settings()
|
||||
context.bare_dec_svc = DecisionService()
|
||||
context.bare_lifecycle = PlanLifecycleService(
|
||||
settings=settings,
|
||||
decision_service=context.bare_dec_svc,
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Action and plan setup
|
||||
# ================================================================
|
||||
|
||||
|
||||
@given('an action "{name}" with action invariants')
|
||||
def step_create_action_with_invariants(context: Context, name: str) -> None:
|
||||
"""Create an action with invariants from the table."""
|
||||
invariant_texts: list[str] = []
|
||||
for row in context.table:
|
||||
invariant_texts.append(row["text"])
|
||||
|
||||
context.enforcement_action = context.enforcement_lifecycle.create_action(
|
||||
name=name,
|
||||
description=f"Action {name}",
|
||||
definition_of_done="All checks pass",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
invariants=invariant_texts,
|
||||
)
|
||||
|
||||
|
||||
@given('an action "{name}" with no action invariants')
|
||||
def step_create_action_without_invariants(context: Context, name: str) -> None:
|
||||
"""Create an action without invariants."""
|
||||
context.enforcement_action = context.enforcement_lifecycle.create_action(
|
||||
name=name,
|
||||
description=f"Action {name}",
|
||||
definition_of_done="All checks pass",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
)
|
||||
|
||||
|
||||
@given('an action "{name}" created on the bare service')
|
||||
def step_create_action_bare(context: Context, name: str) -> None:
|
||||
"""Create an action on the bare lifecycle service."""
|
||||
context.bare_action = context.bare_lifecycle.create_action(
|
||||
name=name,
|
||||
description=f"Action {name}",
|
||||
definition_of_done="All checks pass",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
)
|
||||
|
||||
|
||||
@given('a plan created from invariant-action "{name}"')
|
||||
def step_create_plan_from_action(context: Context, name: str) -> None:
|
||||
"""Use the action to create a plan."""
|
||||
context.enforced_plan = context.enforcement_lifecycle.use_action(
|
||||
action_name=name,
|
||||
)
|
||||
context.enforced_plan_id = context.enforced_plan.identity.plan_id
|
||||
|
||||
|
||||
@given('a plan created from bare-service action "{name}"')
|
||||
def step_create_plan_from_bare_action(context: Context, name: str) -> None:
|
||||
"""Use the bare-service action to create a plan."""
|
||||
context.bare_plan = context.bare_lifecycle.use_action(
|
||||
action_name=name,
|
||||
)
|
||||
context.bare_plan_id = context.bare_plan.identity.plan_id
|
||||
|
||||
|
||||
@given('a global invariant "{text}" in the invariant service')
|
||||
def step_register_global_invariant(context: Context, text: str) -> None:
|
||||
"""Register a global invariant in the invariant service."""
|
||||
context.inv_enforcement_svc.add_invariant(
|
||||
text=text,
|
||||
scope=InvariantScope.GLOBAL,
|
||||
source_name="system",
|
||||
)
|
||||
|
||||
|
||||
@given('a project invariant "{text}" in the invariant service for project "{project}"')
|
||||
def step_register_project_invariant(context: Context, text: str, project: str) -> None:
|
||||
"""Register a project invariant in the invariant service."""
|
||||
context.inv_enforcement_svc.add_invariant(
|
||||
text=text,
|
||||
scope=InvariantScope.PROJECT,
|
||||
source_name=project,
|
||||
)
|
||||
|
||||
|
||||
@given('the enforced plan targets project "{project}"')
|
||||
def step_plan_targets_project(context: Context, project: str) -> None:
|
||||
"""Add a project link to the current plan."""
|
||||
link = ProjectLink(project_name=project)
|
||||
context.enforced_plan.project_links = [link]
|
||||
|
||||
|
||||
@given("the invariant service is rigged to fail")
|
||||
def step_invariant_service_fails(context: Context) -> None:
|
||||
"""Replace invariant service with a mock that raises."""
|
||||
failing_svc = MagicMock(spec=InvariantService)
|
||||
failing_svc.list_invariants.side_effect = RuntimeError(
|
||||
"Simulated invariant service failure"
|
||||
)
|
||||
failing_svc.add_invariant.side_effect = RuntimeError(
|
||||
"Simulated invariant service failure"
|
||||
)
|
||||
context.enforcement_lifecycle._invariant_service = failing_svc
|
||||
|
||||
|
||||
# ================================================================
|
||||
# When steps
|
||||
# ================================================================
|
||||
|
||||
|
||||
@when("I begin strategize with invariant enforcement on the plan")
|
||||
def step_start_strategize(context: Context) -> None:
|
||||
"""Start the strategize phase on the current plan."""
|
||||
context.enforced_plan = context.enforcement_lifecycle.start_strategize(
|
||||
context.enforced_plan_id,
|
||||
)
|
||||
|
||||
|
||||
@when("I begin strategize on the bare-service plan")
|
||||
def step_start_strategize_bare(context: Context) -> None:
|
||||
"""Start strategize on the bare-service plan."""
|
||||
context.bare_plan = context.bare_lifecycle.start_strategize(
|
||||
context.bare_plan_id,
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Then steps
|
||||
# ================================================================
|
||||
|
||||
|
||||
@then("the enforced plan should be in processing state")
|
||||
def step_plan_processing(context: Context) -> None:
|
||||
"""Assert the plan is in PROCESSING state."""
|
||||
assert context.enforced_plan.processing_state == ProcessingState.PROCESSING, (
|
||||
f"Expected PROCESSING, got {context.enforced_plan.processing_state}"
|
||||
)
|
||||
|
||||
|
||||
@then("the bare-service plan should be in processing state")
|
||||
def step_bare_plan_processing(context: Context) -> None:
|
||||
"""Assert the bare plan is in PROCESSING state."""
|
||||
assert context.bare_plan.processing_state == ProcessingState.PROCESSING, (
|
||||
f"Expected PROCESSING, got {context.bare_plan.processing_state}"
|
||||
)
|
||||
|
||||
|
||||
@then("invariant_enforced decisions should exist in the decision tree")
|
||||
def step_invariant_decisions_recorded(context: Context) -> None:
|
||||
"""Assert that at least one invariant_enforced decision exists."""
|
||||
decisions = context.dec_enforcement_svc.list_decisions(context.enforced_plan_id)
|
||||
invariant_decisions = [
|
||||
d for d in decisions if d.decision_type == DecisionType.INVARIANT_ENFORCED
|
||||
]
|
||||
assert len(invariant_decisions) > 0, (
|
||||
"Expected at least one invariant_enforced decision, found none"
|
||||
)
|
||||
|
||||
|
||||
@then('the decision tree should have an invariant_enforced for "{text}"')
|
||||
def step_decision_contains_invariant_text(context: Context, text: str) -> None:
|
||||
"""Assert an invariant_enforced decision exists mentioning the text."""
|
||||
decisions = context.dec_enforcement_svc.list_decisions(context.enforced_plan_id)
|
||||
invariant_decisions = [
|
||||
d for d in decisions if d.decision_type == DecisionType.INVARIANT_ENFORCED
|
||||
]
|
||||
found = any(text in d.chosen_option for d in invariant_decisions)
|
||||
assert found, (
|
||||
f"No invariant_enforced decision found for '{text}'. "
|
||||
f"Decisions: {[d.chosen_option for d in invariant_decisions]}"
|
||||
)
|
||||
|
||||
|
||||
@then("{count:d} invariant_enforced decisions should be in the decision tree")
|
||||
def step_invariant_decision_count(context: Context, count: int) -> None:
|
||||
"""Assert the exact number of invariant_enforced decisions."""
|
||||
decisions = context.dec_enforcement_svc.list_decisions(context.enforced_plan_id)
|
||||
invariant_decisions = [
|
||||
d for d in decisions if d.decision_type == DecisionType.INVARIANT_ENFORCED
|
||||
]
|
||||
actual = len(invariant_decisions)
|
||||
assert actual == count, (
|
||||
f"Expected {count} invariant_enforced decisions, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
@then("the invariant_enforced decision count should be at least {count:d}")
|
||||
def step_decision_count_at_least(context: Context, count: int) -> None:
|
||||
"""Assert the plan has at least the given number of invariant decisions."""
|
||||
decisions = context.dec_enforcement_svc.list_decisions(context.enforced_plan_id)
|
||||
invariant_decisions = [
|
||||
d for d in decisions if d.decision_type == DecisionType.INVARIANT_ENFORCED
|
||||
]
|
||||
actual = len(invariant_decisions)
|
||||
assert actual >= count, (
|
||||
f"Expected at least {count} invariant_enforced decisions, got {actual}"
|
||||
)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Robot Framework helper for invariant enforcement during Strategize.
|
||||
|
||||
Exercises PlanLifecycleService.start_strategize with invariant enforcement
|
||||
wired, verifying that invariant_enforced decisions appear in the decision
|
||||
tree after strategize starts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Ensure source tree is importable
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from cleveragents.application.services.decision_service import ( # noqa: E402
|
||||
DecisionService,
|
||||
)
|
||||
from cleveragents.application.services.invariant_service import ( # noqa: E402
|
||||
InvariantService,
|
||||
)
|
||||
from cleveragents.application.services.plan_lifecycle_service import ( # noqa: E402
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings # noqa: E402
|
||||
from cleveragents.domain.models.core.decision import DecisionType # noqa: E402
|
||||
from cleveragents.domain.models.core.invariant import InvariantScope # noqa: E402
|
||||
|
||||
|
||||
def _enforce_on_strategize() -> dict[str, object]:
|
||||
"""Test that invariants are enforced when strategize starts."""
|
||||
settings = Settings()
|
||||
inv_svc = InvariantService()
|
||||
dec_svc = DecisionService()
|
||||
svc = PlanLifecycleService(
|
||||
settings=settings,
|
||||
decision_service=dec_svc,
|
||||
invariant_service=inv_svc,
|
||||
)
|
||||
|
||||
# Create action with an invariant
|
||||
svc.create_action(
|
||||
name="local/test-enforce",
|
||||
description="Test enforcement",
|
||||
definition_of_done="All checks pass",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
invariants=["All tests must pass"],
|
||||
)
|
||||
|
||||
# Add a global invariant
|
||||
inv_svc.add_invariant(
|
||||
"Never delete production data",
|
||||
InvariantScope.GLOBAL,
|
||||
"system",
|
||||
)
|
||||
|
||||
# Create plan and start strategize
|
||||
plan = svc.use_action(action_name="local/test-enforce")
|
||||
plan = svc.start_strategize(plan.identity.plan_id)
|
||||
|
||||
# Count invariant_enforced decisions
|
||||
decisions = dec_svc.list_decisions(plan.identity.plan_id)
|
||||
inv_decisions = [
|
||||
d for d in decisions if d.decision_type == DecisionType.INVARIANT_ENFORCED
|
||||
]
|
||||
|
||||
return {
|
||||
"plan_state": plan.processing_state.value
|
||||
if plan.processing_state
|
||||
else "unknown",
|
||||
"decision_count": len(inv_decisions),
|
||||
}
|
||||
|
||||
|
||||
def _no_invariant_service() -> dict[str, object]:
|
||||
"""Test strategize works without invariant service."""
|
||||
settings = Settings()
|
||||
dec_svc = DecisionService()
|
||||
svc = PlanLifecycleService(
|
||||
settings=settings,
|
||||
decision_service=dec_svc,
|
||||
)
|
||||
|
||||
svc.create_action(
|
||||
name="local/no-inv",
|
||||
description="No invariants",
|
||||
definition_of_done="All checks pass",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
)
|
||||
|
||||
plan = svc.use_action(action_name="local/no-inv")
|
||||
plan = svc.start_strategize(plan.identity.plan_id)
|
||||
|
||||
decisions = dec_svc.list_decisions(plan.identity.plan_id)
|
||||
inv_decisions = [
|
||||
d for d in decisions if d.decision_type == DecisionType.INVARIANT_ENFORCED
|
||||
]
|
||||
|
||||
return {
|
||||
"plan_state": plan.processing_state.value
|
||||
if plan.processing_state
|
||||
else "unknown",
|
||||
"decision_count": len(inv_decisions),
|
||||
}
|
||||
|
||||
|
||||
def _multi_scope() -> dict[str, object]:
|
||||
"""Test multi-scope invariant reconciliation during strategize."""
|
||||
settings = Settings()
|
||||
inv_svc = InvariantService()
|
||||
dec_svc = DecisionService()
|
||||
svc = PlanLifecycleService(
|
||||
settings=settings,
|
||||
decision_service=dec_svc,
|
||||
invariant_service=inv_svc,
|
||||
)
|
||||
|
||||
# Add invariants from multiple scopes
|
||||
inv_svc.add_invariant(
|
||||
"Backward compatibility required",
|
||||
InvariantScope.GLOBAL,
|
||||
"system",
|
||||
)
|
||||
inv_svc.add_invariant(
|
||||
"Use ORM for queries",
|
||||
InvariantScope.PROJECT,
|
||||
"local/api-service",
|
||||
)
|
||||
|
||||
svc.create_action(
|
||||
name="local/multi-scope-enforce",
|
||||
description="Multi-scope test",
|
||||
definition_of_done="All checks pass",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
invariants=["Mock all network calls"],
|
||||
)
|
||||
|
||||
from cleveragents.domain.models.core.plan import ProjectLink
|
||||
|
||||
plan = svc.use_action(
|
||||
action_name="local/multi-scope-enforce",
|
||||
project_links=[ProjectLink(project_name="local/api-service")],
|
||||
)
|
||||
plan = svc.start_strategize(plan.identity.plan_id)
|
||||
|
||||
decisions = dec_svc.list_decisions(plan.identity.plan_id)
|
||||
inv_decisions = [
|
||||
d for d in decisions if d.decision_type == DecisionType.INVARIANT_ENFORCED
|
||||
]
|
||||
|
||||
return {
|
||||
"plan_state": plan.processing_state.value
|
||||
if plan.processing_state
|
||||
else "unknown",
|
||||
"decision_count": len(inv_decisions),
|
||||
}
|
||||
|
||||
|
||||
def _resilient() -> dict[str, object]:
|
||||
"""Test strategize proceeds despite invariant enforcement failure."""
|
||||
settings = Settings()
|
||||
failing_inv_svc = MagicMock(spec=InvariantService)
|
||||
failing_inv_svc.list_invariants.side_effect = RuntimeError("Simulated failure")
|
||||
failing_inv_svc.add_invariant.side_effect = RuntimeError("Simulated failure")
|
||||
|
||||
dec_svc = DecisionService()
|
||||
svc = PlanLifecycleService(
|
||||
settings=settings,
|
||||
decision_service=dec_svc,
|
||||
invariant_service=failing_inv_svc,
|
||||
)
|
||||
|
||||
svc.create_action(
|
||||
name="local/resilient",
|
||||
description="Resilience test",
|
||||
definition_of_done="All checks pass",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
)
|
||||
|
||||
plan = svc.use_action(action_name="local/resilient")
|
||||
plan = svc.start_strategize(plan.identity.plan_id)
|
||||
|
||||
return {
|
||||
"plan_state": plan.processing_state.value
|
||||
if plan.processing_state
|
||||
else "unknown",
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for Robot Framework helper."""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: helper_invariant_enforcement_strategize.py <command>")
|
||||
sys.exit(1)
|
||||
|
||||
command = sys.argv[1]
|
||||
handlers: dict[str, object] = {
|
||||
"enforce-on-strategize": _enforce_on_strategize,
|
||||
"no-invariant-service": _no_invariant_service,
|
||||
"multi-scope": _multi_scope,
|
||||
"resilient": _resilient,
|
||||
}
|
||||
|
||||
handler = handlers.get(command)
|
||||
if handler is None:
|
||||
print(f"Unknown command: {command}")
|
||||
sys.exit(1)
|
||||
|
||||
if callable(handler):
|
||||
result = handler()
|
||||
print(json.dumps(result))
|
||||
print("enforcement-ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
*** Settings ***
|
||||
Documentation Integration tests for invariant enforcement during Strategize phase
|
||||
... Verifies that PlanLifecycleService invokes the Invariant Reconciliation Actor
|
||||
... at the start of Strategize, recording invariant_enforced decisions
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_invariant_enforcement_strategize.py
|
||||
|
||||
*** Test Cases ***
|
||||
Invariants Enforced During Strategize Start
|
||||
[Documentation] When strategize starts, invariants from action and global scope are reconciled
|
||||
${result}= Run Process ${PYTHON} ${HELPER} enforce-on-strategize cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} enforcement-ok
|
||||
Should Contain ${result.stdout} "decision_count": 2
|
||||
Should Contain ${result.stdout} "plan_state": "processing"
|
||||
|
||||
Strategize Without Invariant Service
|
||||
[Documentation] Strategize succeeds when no InvariantService is wired
|
||||
${result}= Run Process ${PYTHON} ${HELPER} no-invariant-service cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} enforcement-ok
|
||||
Should Contain ${result.stdout} "plan_state": "processing"
|
||||
Should Contain ${result.stdout} "decision_count": 0
|
||||
|
||||
Strategize With Multi-Scope Invariants
|
||||
[Documentation] Strategize reconciles invariants from global, project, and plan scopes
|
||||
${result}= Run Process ${PYTHON} ${HELPER} multi-scope cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} enforcement-ok
|
||||
Should Contain ${result.stdout} "plan_state": "processing"
|
||||
|
||||
Strategize Resilient To Invariant Failure
|
||||
[Documentation] Strategize proceeds even if invariant enforcement fails
|
||||
${result}= Run Process ${PYTHON} ${HELPER} resilient cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} enforcement-ok
|
||||
Should Contain ${result.stdout} "plan_state": "processing"
|
||||
@@ -45,6 +45,7 @@ from cleveragents.application.services.execution_environment_resolver import (
|
||||
from cleveragents.application.services.fix_then_revalidate import (
|
||||
FixThenRevalidateOrchestrator,
|
||||
)
|
||||
from cleveragents.application.services.invariant_service import InvariantService
|
||||
from cleveragents.application.services.multi_project_service import (
|
||||
MultiProjectService,
|
||||
)
|
||||
@@ -581,6 +582,11 @@ class Container(containers.DeclarativeContainer):
|
||||
event_bus=event_bus,
|
||||
)
|
||||
|
||||
# Invariant Service - Singleton (in-memory state shared across callers)
|
||||
invariant_service = providers.Singleton(
|
||||
InvariantService,
|
||||
)
|
||||
|
||||
# Plan Lifecycle Service - Factory (v3 four-phase lifecycle)
|
||||
plan_lifecycle_service = providers.Factory(
|
||||
PlanLifecycleService,
|
||||
@@ -588,6 +594,7 @@ class Container(containers.DeclarativeContainer):
|
||||
unit_of_work=unit_of_work,
|
||||
decision_service=decision_service,
|
||||
event_bus=event_bus,
|
||||
invariant_service=invariant_service,
|
||||
)
|
||||
|
||||
# Checkpoint Service - database-backed via CheckpointRepository
|
||||
|
||||
@@ -96,6 +96,7 @@ if TYPE_CHECKING:
|
||||
from cleveragents.application.services.error_pattern_service import (
|
||||
ErrorPatternService,
|
||||
)
|
||||
from cleveragents.application.services.invariant_service import InvariantService
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
|
||||
from cleveragents.infrastructure.events.protocol import EventBus
|
||||
@@ -166,6 +167,7 @@ class PlanLifecycleService:
|
||||
event_bus: EventBus | None = None,
|
||||
job_store: InMemoryJobStore | None = None,
|
||||
error_pattern_service: ErrorPatternService | None = None,
|
||||
invariant_service: InvariantService | None = None,
|
||||
):
|
||||
"""Initialize the plan lifecycle service.
|
||||
|
||||
@@ -194,6 +196,14 @@ class PlanLifecycleService:
|
||||
consulted before the Execute phase to inject preventive
|
||||
guidance. When ``None``, predictive prevention is
|
||||
silently skipped.
|
||||
invariant_service: Optional
|
||||
:class:`InvariantService` for invariant enforcement
|
||||
during the Strategize phase. When provided, the
|
||||
Invariant Reconciliation Actor is invoked at the start
|
||||
of Strategize to collect, reconcile, and enforce
|
||||
invariants as ``invariant_enforced`` decisions.
|
||||
When ``None``, invariant enforcement is silently
|
||||
skipped.
|
||||
"""
|
||||
self.settings = settings
|
||||
self.unit_of_work = unit_of_work
|
||||
@@ -201,6 +211,7 @@ class PlanLifecycleService:
|
||||
self.event_bus = event_bus
|
||||
self._job_store = job_store
|
||||
self.error_pattern_service = error_pattern_service
|
||||
self._invariant_service = invariant_service
|
||||
self._logger = logger.bind(service="plan_lifecycle")
|
||||
self.preflight_guardrail = PlanPreflightGuardrail()
|
||||
|
||||
@@ -281,6 +292,73 @@ class PlanLifecycleService:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _enforce_invariants(self, plan: Plan) -> None:
|
||||
"""Invoke the Invariant Reconciliation Actor at Strategize start.
|
||||
|
||||
Collects invariants from all scopes (global, project, action,
|
||||
plan), reconciles conflicts using plan > project > global
|
||||
precedence, and records ``invariant_enforced`` decisions in the
|
||||
plan's decision tree.
|
||||
|
||||
Requires both ``_invariant_service`` and ``decision_service``
|
||||
to be wired. When either is ``None``, invariant enforcement is
|
||||
silently skipped.
|
||||
|
||||
Failures are logged but never propagated — invariant enforcement
|
||||
must not block lifecycle transitions.
|
||||
"""
|
||||
if self._invariant_service is None or self.decision_service is None:
|
||||
return
|
||||
|
||||
try:
|
||||
from cleveragents.actor.reconciliation import (
|
||||
InvariantReconciliationActor,
|
||||
)
|
||||
|
||||
actor = InvariantReconciliationActor(
|
||||
invariant_service=self._invariant_service,
|
||||
decision_service=self.decision_service,
|
||||
)
|
||||
|
||||
# Extract project and action names from the plan
|
||||
project_name: str | None = None
|
||||
if plan.project_links:
|
||||
project_name = plan.project_links[0].project_name
|
||||
|
||||
action_name: str | None = plan.action_name
|
||||
|
||||
# Also register any plan-level invariants from the plan
|
||||
# model into the invariant service so the actor can
|
||||
# discover them during collection.
|
||||
from cleveragents.domain.models.core.invariant import InvariantScope
|
||||
|
||||
for inv in plan.invariants or []:
|
||||
self._invariant_service.add_invariant(
|
||||
text=inv.text,
|
||||
scope=InvariantScope.PLAN,
|
||||
source_name=plan.identity.plan_id,
|
||||
)
|
||||
|
||||
result = actor.run(
|
||||
plan_id=plan.identity.plan_id,
|
||||
project_name=project_name,
|
||||
action_name=action_name,
|
||||
)
|
||||
|
||||
self._logger.info(
|
||||
"Invariant enforcement completed",
|
||||
plan_id=plan.identity.plan_id,
|
||||
effective_count=len(result.reconciled_set.invariants),
|
||||
conflict_count=len(result.conflicts),
|
||||
decision_count=len(result.enforced_decision_ids),
|
||||
)
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
"invariant_enforcement_failed",
|
||||
plan_id=plan.identity.plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _run_estimation(self, plan: Plan) -> None:
|
||||
"""Run the estimation actor if configured on the plan.
|
||||
|
||||
@@ -999,6 +1077,13 @@ class PlanLifecycleService:
|
||||
chosen_option=f"Begin strategize phase for plan {plan_id}",
|
||||
)
|
||||
|
||||
# -- Invariant enforcement ----------------------------------------
|
||||
# Per spec §19554-19610: the Invariant Reconciliation Actor is
|
||||
# invoked at the start of Strategize to compute effective
|
||||
# invariants and record invariant_enforced decisions.
|
||||
self._enforce_invariants(plan)
|
||||
# -- End invariant enforcement ------------------------------------
|
||||
|
||||
return plan
|
||||
|
||||
def complete_strategize(self, plan_id: str) -> Plan:
|
||||
|
||||
Reference in New Issue
Block a user