feat(autonomy): automation profile resolution precedence correct (#1196)

## Summary
- Fix automation profile resolution precedence at plan-creation time to follow plan > action > project > global.
- Narrow exception handling in `_resolve_plan_profile_ref` to catch only `(KeyError, ValueError, OSError)` with warning-level structured logging, replacing bare `except Exception:` per CONTRIBUTING.md §Exception Propagation.
- Fix TDD feature file tag/title inconsistency: align to `@tdd_bug @tdd_bug_1076` / "TDD Bug #1076" referencing the original bug ticket.
- Add BDD scenarios for plan-level profile override and config service error fallback.
- Harden lifecycle and Robot/Behave test paths for auto-progression and parallel CI stability (including pre-seeded DB initialization for `tdd_plan_explain_plan_id` helper).
- Update Robot helper timing/session patterns to reduce false negatives in long-running integration/E2E validations.

## Approach
The automation profile resolution is implemented in `PlanLifecycleService._resolve_plan_profile_ref`, which evaluates the four-level precedence chain (plan > action > project > global) at `use_action()` time. The resolved profile is stored as an `AutomationProfileRef` on the Plan with provenance tracking. The `ConfigService` is used for project-scoped and global config lookups, with narrowed exception handling that logs warnings on failure and falls back to the settings default.

## Validation
- `nox -s lint` 
- `nox -s typecheck` 
- `nox -s unit_tests` 
- `nox -s coverage_report`  (97%)

## Review Fix Round (Review #2963)
1. **Bare `except Exception:` → narrowed to `(KeyError, ValueError, OSError)` with warning logging** — addresses CONTRIBUTING.md §Exception Propagation violation
2. **Tag inconsistency fixed** — `@tdd_bug @tdd_bug_1076` with title "TDD Bug #1076"
3. **Added 2 new BDD scenarios** — plan-level override and config error fallback
4. **Rebased onto latest master** (532ea100)

Closes #854

Reviewed-on: cleveragents/cleveragents-core#1196
Co-authored-by: Brent Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent Edwards <brent.edwards@cleverthis.com>
This commit is contained in:
2026-04-01 04:27:09 +00:00
committed by Forgejo
parent 81c141716b
commit 34dcf5226f
7 changed files with 313 additions and 48 deletions
+31 -10
View File
@@ -24,6 +24,7 @@ from cleveragents.domain.models.core.action import (
)
from cleveragents.domain.models.core.plan import (
PlanPhase,
ProcessingState,
ProjectLink,
)
@@ -468,40 +469,60 @@ def step_create_plan_strategize_complete(context: Context) -> None:
def step_create_plan_execute_queued(context: Context) -> None:
"""Create a plan in execute phase with queued state."""
step_create_plan_strategize_complete(context)
context.plan = context.lifecycle_service.execute_plan(context.plan.identity.plan_id)
if context.plan.phase == PlanPhase.STRATEGIZE:
context.plan = context.lifecycle_service.execute_plan(
context.plan.identity.plan_id
)
@given("I have a plan in execute phase with processing state")
def step_create_plan_execute_processing(context: Context) -> None:
"""Create a plan in execute phase with processing state."""
step_create_plan_execute_queued(context)
context.plan = context.lifecycle_service.start_execute(
context.plan.identity.plan_id
)
if context.plan.phase == PlanPhase.EXECUTE and (
context.plan.processing_state == ProcessingState.QUEUED
):
context.plan = context.lifecycle_service.start_execute(
context.plan.identity.plan_id
)
@given("I have a plan in execute phase with complete state")
def step_create_plan_execute_complete(context: Context) -> None:
"""Create a plan in execute phase with complete state."""
step_create_plan_execute_processing(context)
context.plan = context.lifecycle_service.complete_execute(
context.plan.identity.plan_id
)
if context.plan.phase == PlanPhase.EXECUTE and (
context.plan.processing_state == ProcessingState.PROCESSING
):
context.plan = context.lifecycle_service.complete_execute(
context.plan.identity.plan_id
)
@given("I have a plan in apply phase with queued state")
def step_create_plan_apply_queued(context: Context) -> None:
"""Create a plan in apply phase with queued state."""
step_create_plan_execute_complete(context)
context.plan = context.lifecycle_service.apply_plan(context.plan.identity.plan_id)
if context.plan.phase == PlanPhase.EXECUTE:
context.plan = context.lifecycle_service.apply_plan(
context.plan.identity.plan_id
)
@given("I have a plan in apply phase with processing state")
def step_create_plan_apply_processing(context: Context) -> None:
"""Create a plan in apply phase with processing state."""
step_create_plan_execute_complete(context)
context.plan = context.lifecycle_service.apply_plan(context.plan.identity.plan_id)
context.plan = context.lifecycle_service.start_apply(context.plan.identity.plan_id)
if context.plan.phase == PlanPhase.EXECUTE:
context.plan = context.lifecycle_service.apply_plan(
context.plan.identity.plan_id
)
if context.plan.phase == PlanPhase.APPLY and (
context.plan.processing_state == ProcessingState.QUEUED
):
context.plan = context.lifecycle_service.start_apply(
context.plan.identity.plan_id
)
@given("I have a plan in applied phase")
@@ -23,11 +23,15 @@ from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.config_service import ConfigService
from cleveragents.application.services.config_service import (
ConfigService,
ResolvedValue,
)
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
@@ -62,11 +66,57 @@ def _use_action_on_project(context: Context, project: str) -> None:
# ---------------------------------------------------------------------------
class _BrokenConfigService(ConfigService):
"""A ConfigService subclass that always raises on resolve().
Used by the fallback-on-error scenario to verify that the exception
handler in ``_resolve_plan_profile_ref`` catches expected errors
and falls back to the settings default gracefully.
"""
def resolve(
self,
key: str,
*,
cli_value: Any | None = None,
project_name: str | None = None,
verbose: bool = False,
) -> ResolvedValue:
raise ValueError(f"Simulated config resolution failure for key '{key}'")
@given("a plan lifecycle service with a broken config service")
def step_create_lifecycle_service_broken_config(context: Context) -> None:
"""Create a PlanLifecycleService with a config service that always errors."""
tmpdir: str = tempfile.mkdtemp()
tmp_path: Path = Path(tmpdir)
context.ap_config_tmpdir = tmpdir
broken_config = _BrokenConfigService(
config_dir=tmp_path,
config_path=tmp_path / "config.toml",
)
settings: Settings = Settings()
context.ap_lifecycle_service = PlanLifecycleService(
settings=settings,
config_service=broken_config,
)
@given("a plan lifecycle service for automation profile testing")
def step_create_lifecycle_service(context: Context) -> None:
"""Create a PlanLifecycleService instance for automation profile tests."""
tmpdir: str = tempfile.mkdtemp()
tmp_path: Path = Path(tmpdir)
context.ap_config_tmpdir = tmpdir
context.ap_config_service = ConfigService(
config_dir=tmp_path,
config_path=tmp_path / "config.toml",
)
settings: Settings = Settings()
context.ap_lifecycle_service = PlanLifecycleService(settings=settings)
context.ap_lifecycle_service = PlanLifecycleService(
settings=settings,
config_service=context.ap_config_service,
)
@given('an available action "{name}" with automation_profile "{profile}"')
@@ -111,15 +161,9 @@ def step_set_project_scoped_profile(
value at level 3. ``use_action()`` should consult this when the
action itself has no automation_profile.
"""
tmpdir: str = tempfile.mkdtemp()
context.ap_config_tmpdir = tmpdir
tmp_path: Path = Path(tmpdir)
config_svc: ConfigService = ConfigService(
config_dir=tmp_path,
config_path=tmp_path / "config.toml",
context.ap_config_service.set_project_value(
project, "core.automation-profile", profile
)
config_svc.set_project_value(project, "core.automation-profile", profile)
context.ap_config_service = config_svc
# ---------------------------------------------------------------------------
@@ -133,6 +177,20 @@ def step_use_profiled_action(context: Context, project: str) -> None:
_use_action_on_project(context, project)
@when('I use the action with plan-level profile "{profile}" on project "{project}"')
def step_use_action_with_plan_profile(
context: Context, profile: str, project: str
) -> None:
"""Use the action with an explicit plan-level profile override."""
links: list[ProjectLink] = [ProjectLink(project_name=project)]
plan: Plan = context.ap_lifecycle_service.use_action(
action_name=str(context.ap_action.namespaced_name),
project_links=links,
automation_profile=profile,
)
context.ap_plan = plan
@when('I use the unprofiled action on project "{project}"')
def step_use_unprofiled_action(context: Context, project: str) -> None:
"""Use the unprofiled action on a project via use_action()."""
@@ -1,5 +1,5 @@
@tdd_expected_fail @tdd_issue @tdd_issue_1076
Feature: TDD Issue #1076 — use_action() does not propagate automation_profile to Plan
@tdd_bug @tdd_bug_1076
Feature: TDD Bug #1076 — use_action() does not propagate automation_profile to Plan
As a developer
I want to verify that use_action() resolves the automation profile
from the precedence chain and sets it on the created Plan
@@ -50,3 +50,19 @@ Feature: TDD Issue #1076 — use_action() does not propagate automation_profile
Then the created plan automation_profile should not be None
And the created plan automation_profile name should be "supervised"
And the created plan automation_profile provenance should be "global"
Scenario: Plan falls back to settings default when config service raises an error
Given a plan lifecycle service with a broken config service
And an available action "local/fallback-action" without automation_profile
When I use the unprofiled action on project "test-project"
Then the created plan automation_profile should not be None
And the created plan automation_profile name should be "manual"
And the created plan automation_profile provenance should be "global"
Scenario: Plan-level profile overrides action-level profile
Given a plan lifecycle service for automation profile testing
And an available action "local/override-action" with automation_profile "full-auto"
When I use the action with plan-level profile "ci" on project "test-project"
Then the created plan automation_profile should not be None
And the created plan automation_profile name should be "ci"
And the created plan automation_profile provenance should be "plan"
+65 -10
View File
@@ -193,10 +193,33 @@ def _review_profile_behavior() -> None:
pid = plan.identity.plan_id
service.start_strategize(pid)
p = service.get_plan(pid)
assert p.processing_state == ProcessingState.PROCESSING
service.complete_strategize(pid)
service.execute_plan(pid)
service.start_execute(pid)
plan = service.complete_execute(pid)
p = service.get_plan(pid)
assert (p.phase, p.processing_state) in {
(PlanPhase.STRATEGIZE, ProcessingState.COMPLETE),
(PlanPhase.EXECUTE, ProcessingState.QUEUED),
}, (
"After complete_strategize expected strategize/complete or execute/queued, "
f"got {p.phase.value}/{p.processing_state.value}"
)
if p.phase == PlanPhase.STRATEGIZE:
service.execute_plan(pid)
p = service.get_plan(pid)
if p.phase == PlanPhase.EXECUTE and p.processing_state == ProcessingState.QUEUED:
service.start_execute(pid)
p = service.get_plan(pid)
if (
p.phase == PlanPhase.EXECUTE
and p.processing_state == ProcessingState.PROCESSING
):
plan = service.complete_execute(pid)
else:
plan = p
# Review: auto_progress must be False (apply gated)
assert plan.phase == PlanPhase.EXECUTE
@@ -207,8 +230,13 @@ def _review_profile_behavior() -> None:
# Explicit apply works
service.apply_plan(pid)
service.start_apply(pid)
plan = service.complete_apply(pid)
p = service.get_plan(pid)
if p.phase == PlanPhase.APPLY and p.processing_state == ProcessingState.QUEUED:
service.start_apply(pid)
plan = service.complete_apply(pid)
else:
plan = p
assert plan.processing_state == ProcessingState.APPLIED
assert plan.is_terminal
@@ -567,12 +595,34 @@ def _plan_lifecycle_review_profile() -> None:
# Strategize
service.start_strategize(pid)
p = service.get_plan(pid)
assert p.processing_state == ProcessingState.PROCESSING
service.complete_strategize(pid)
p = service.get_plan(pid)
assert (p.phase, p.processing_state) in {
(PlanPhase.STRATEGIZE, ProcessingState.COMPLETE),
(PlanPhase.EXECUTE, ProcessingState.QUEUED),
}, (
"After complete_strategize expected strategize/complete or execute/queued, "
f"got {p.phase.value}/{p.processing_state.value}"
)
if p.phase == PlanPhase.STRATEGIZE:
service.execute_plan(pid)
p = service.get_plan(pid)
# Execute
service.execute_plan(pid)
service.start_execute(pid)
plan = service.complete_execute(pid)
if p.phase == PlanPhase.EXECUTE and p.processing_state == ProcessingState.QUEUED:
service.start_execute(pid)
p = service.get_plan(pid)
if (
p.phase == PlanPhase.EXECUTE
and p.processing_state == ProcessingState.PROCESSING
):
plan = service.complete_execute(pid)
else:
plan = p
# Review gate blocks auto-progress
assert plan.phase == PlanPhase.EXECUTE
@@ -581,8 +631,13 @@ def _plan_lifecycle_review_profile() -> None:
# Explicit apply
service.apply_plan(pid)
service.start_apply(pid)
plan = service.complete_apply(pid)
p = service.get_plan(pid)
if p.phase == PlanPhase.APPLY and p.processing_state == ProcessingState.QUEUED:
service.start_apply(pid)
plan = service.complete_apply(pid)
else:
plan = p
assert plan.phase == PlanPhase.APPLY
assert plan.processing_state == ProcessingState.APPLIED
assert plan.is_terminal
+8 -5
View File
@@ -372,8 +372,8 @@ def ci_plan_lifecycle() -> None:
profile_svc = AutomationProfileService(repo=None)
ci_profile = profile_svc.resolve_profile(plan_profile="ci")
assert ci_profile.name == "ci"
# NOTE: TODO(#1060) - use_action() does not propagate action profile to plan.
# auto_progress then resolves to manual; keep phase guards until #1060 is fixed.
# use_action now resolves automation profile precedence and can auto-progress
# beyond phase boundaries for non-manual profiles (e.g., ci).
# --- Phase-by-phase plan completion (spec Step 3) ---
service.start_strategize(plan_id)
p = service.get_plan(plan_id)
@@ -398,15 +398,18 @@ def ci_plan_lifecycle() -> None:
assert (p.phase, p.state) in {
(PlanPhase.EXECUTE, ProcessingState.COMPLETE),
(PlanPhase.APPLY, ProcessingState.QUEUED),
(PlanPhase.APPLY, ProcessingState.APPLIED),
}, (
"After complete_execute expected execute/complete or apply/queued, "
"After complete_execute expected execute/complete, apply/queued, "
"or apply/applied, "
f"got {p.phase.value}/{p.state.value}"
)
if p.phase == PlanPhase.EXECUTE:
service.apply_plan(plan_id)
# Apply phase
service.start_apply(plan_id)
service.complete_apply(plan_id)
if p.phase == PlanPhase.APPLY and p.state == ProcessingState.QUEUED:
service.start_apply(plan_id)
service.complete_apply(plan_id)
# Verify terminal state — the polling loop exits on 'applied'
final = service.get_plan(plan_id)
assert final.state == ProcessingState.APPLIED, (
@@ -58,6 +58,7 @@ from typing import TYPE_CHECKING, Any
import structlog
from ulid import ULID
from cleveragents.application.services.config_service import ConfigLevel, ConfigService
from cleveragents.application.services.plan_preflight_guardrail import (
PlanPreflightGuardrail,
)
@@ -76,6 +77,8 @@ from cleveragents.domain.models.core.automation_profile import (
AutomationProfile,
)
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
NamespacedName,
Plan,
@@ -166,6 +169,7 @@ class PlanLifecycleService:
event_bus: EventBus | None = None,
job_store: InMemoryJobStore | None = None,
error_pattern_service: ErrorPatternService | None = None,
config_service: ConfigService | None = None,
):
"""Initialize the plan lifecycle service.
@@ -201,6 +205,7 @@ class PlanLifecycleService:
self.event_bus = event_bus
self._job_store = job_store
self.error_pattern_service = error_pattern_service
self._config_service = config_service
self._logger = logger.bind(service="plan_lifecycle")
self.preflight_guardrail = PlanPreflightGuardrail()
@@ -726,6 +731,7 @@ class PlanLifecycleService:
arguments: dict[str, Any] | None = None,
created_by: str | None = None,
invariants: list[PlanInvariant] | None = None,
automation_profile: str | None = None,
) -> Plan:
"""Use an action on projects to create a plan in Strategize phase.
@@ -739,6 +745,7 @@ class PlanLifecycleService:
arguments: Argument values for the action
created_by: User/session creating the plan
invariants: Additional plan-level invariants
automation_profile: Optional plan-level override profile name
Returns:
The created Plan in Strategize phase
@@ -782,6 +789,11 @@ class PlanLifecycleService:
plan_id = self._generate_ulid()
action_full_name = str(action.namespaced_name)
plan_name = f"{action.namespaced_name.name}-{plan_id[:8]}"
resolved_profile = self._resolve_plan_profile_ref(
plan_profile=automation_profile,
action_profile=action.automation_profile,
project_links=links,
)
plan = Plan(
identity=PlanIdentity(plan_id=plan_id),
@@ -815,6 +827,7 @@ class PlanLifecycleService:
tags=action.tags.copy(),
reusable=action.reusable,
read_only=action.read_only,
automation_profile=resolved_profile,
)
# Persist or store in-memory
@@ -855,6 +868,100 @@ class PlanLifecycleService:
return plan
def _resolve_plan_profile_ref(
self,
*,
plan_profile: str | None,
action_profile: str | None,
project_links: list[ProjectLink],
) -> AutomationProfileRef:
"""Resolve and record automation profile precedence for a new plan.
Precedence chain: plan > action > project > global.
"""
normalized_plan_profile = (
plan_profile.strip() if isinstance(plan_profile, str) else None
)
normalized_action_profile = (
action_profile.strip() if isinstance(action_profile, str) else None
)
project_name = project_links[0].project_name if project_links else None
project_profile: str | None = None
raw_settings_profile = getattr(self.settings, "default_automation_profile", "")
settings_fallback = (
raw_settings_profile.strip()
if isinstance(raw_settings_profile, str)
else ""
)
if not settings_fallback or settings_fallback not in BUILTIN_PROFILES:
settings_fallback = "manual"
global_profile: str = settings_fallback
if self._config_service is not None:
try:
global_resolved = self._config_service.resolve(
"core.automation-profile"
)
if isinstance(global_resolved.value, str):
resolved_global_value = global_resolved.value.strip()
if resolved_global_value:
global_profile = resolved_global_value
if project_name:
project_resolved = self._config_service.resolve(
"core.automation-profile",
project_name=project_name,
)
if project_resolved.source is ConfigLevel.PROJECT and isinstance(
project_resolved.value, str
):
candidate = project_resolved.value.strip()
if candidate:
project_profile = candidate
except (KeyError, ValueError, OSError) as exc:
self._logger.warning(
"Config resolution failed for automation profile, "
"falling back to settings default",
error=str(exc),
error_type=type(exc).__name__,
settings_fallback=settings_fallback,
)
global_profile = settings_fallback
chosen_profile = global_profile
provenance = AutomationProfileProvenance.GLOBAL
if project_profile:
chosen_profile = project_profile
provenance = AutomationProfileProvenance.PROJECT
if normalized_action_profile:
chosen_profile = normalized_action_profile
provenance = AutomationProfileProvenance.ACTION
if normalized_plan_profile:
chosen_profile = normalized_plan_profile
provenance = AutomationProfileProvenance.PLAN
if chosen_profile not in BUILTIN_PROFILES:
raise ValidationError(
f"Unknown automation profile '{chosen_profile}'. "
f"Available built-ins: {', '.join(sorted(BUILTIN_PROFILES))}"
)
self._logger.info(
"Resolved automation profile for plan",
resolved_profile=chosen_profile,
provenance=provenance.value,
plan_profile=normalized_plan_profile,
action_profile=normalized_action_profile,
project_profile=project_profile,
global_profile=global_profile,
project_name=project_name,
)
return AutomationProfileRef(
profile_name=chosen_profile,
provenance=provenance,
)
def get_plan(self, plan_id: str) -> Plan:
"""Get a plan by ID.
+16 -11
View File
@@ -1662,6 +1662,18 @@ def use_action(
# Get action by name
action = service.get_action_by_name(action_name)
if automation_profile:
from cleveragents.domain.models.core.automation_profile import (
BUILTIN_PROFILES,
)
if automation_profile not in BUILTIN_PROFILES:
console.print(
f"[red]Invalid automation profile:[/red] {automation_profile}. "
f"Available: {', '.join(sorted(BUILTIN_PROFILES))}"
)
raise typer.Abort()
# Build project links from project names
project_links = [ProjectLink(project_name=p) for p in all_projects]
@@ -1679,6 +1691,7 @@ def use_action(
project_links=project_links,
arguments=arguments if arguments else None,
invariants=plan_invariants,
automation_profile=automation_profile,
)
# Notify A2A facade for protocol bookkeeping
@@ -1694,18 +1707,10 @@ def use_action(
# re-persist the plan when necessary.
has_overrides = False
# Apply automation profile override if provided
# Preserve explicit CLI override locally as well so CLI output,
# persistence refresh, and mocked lifecycle-service tests all
# observe the plan-level profile consistently.
if automation_profile:
from cleveragents.domain.models.core.automation_profile import (
BUILTIN_PROFILES,
)
if automation_profile not in BUILTIN_PROFILES:
console.print(
f"[red]Invalid automation profile:[/red] {automation_profile}. "
f"Available: {', '.join(sorted(BUILTIN_PROFILES))}"
)
raise typer.Abort()
plan.automation_profile = AutomationProfileRef(
profile_name=automation_profile,
provenance=AutomationProfileProvenance.PLAN,