feat(domain): rebaseline plan phases and apply states
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 31s
CI / security (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 15s
CI / integration_tests (pull_request) Successful in 4m37s
CI / build (pull_request) Successful in 15s
CI / lint (push) Successful in 12s
CI / typecheck (push) Successful in 27s
CI / security (push) Successful in 22s
CI / quality (push) Successful in 15s
CI / unit_tests (pull_request) Successful in 9m56s
CI / integration_tests (push) Successful in 4m41s
CI / build (push) Successful in 15s
CI / unit_tests (push) Successful in 9m58s
CI / coverage (pull_request) Successful in 7m1s
CI / docker (pull_request) Successful in 8s
CI / docker (push) Successful in 8s
CI / coverage (push) Successful in 6m54s

This commit was merged in pull request #66.
This commit is contained in:
2026-02-15 03:32:13 +00:00
parent 88e8957cba
commit 039ab87b52
16 changed files with 451 additions and 130 deletions
+116
View File
@@ -0,0 +1,116 @@
"""ASV benchmarks for plan phase transition validation overhead.
Measures the cost of:
- ``can_transition()`` lookups
- ``Plan.get_next_phase()`` traversals
- ``Plan.is_terminal`` property checks
- ``Plan.can_transition_to_next_phase`` property checks
"""
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
ProcessingState,
can_transition,
)
def _make_plan(
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
) -> Plan:
return Plan(
identity=PlanIdentity(plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV"),
namespaced_name=NamespacedName(server=None, namespace="local", name="bench"),
description="Benchmark plan",
action_name="local/bench-action",
phase=phase,
processing_state=processing_state,
)
class TimeCanTransition:
"""Benchmark ``can_transition()`` function."""
def time_valid_transition_strategize_to_execute(self) -> None:
can_transition(PlanPhase.STRATEGIZE, PlanPhase.EXECUTE)
def time_valid_transition_action_to_strategize(self) -> None:
can_transition(PlanPhase.ACTION, PlanPhase.STRATEGIZE)
def time_invalid_transition_strategize_to_apply(self) -> None:
can_transition(PlanPhase.STRATEGIZE, PlanPhase.APPLY)
def time_revert_execute_to_strategize(self) -> None:
can_transition(PlanPhase.EXECUTE, PlanPhase.STRATEGIZE)
def time_revert_apply_to_strategize(self) -> None:
can_transition(PlanPhase.APPLY, PlanPhase.STRATEGIZE)
class TimeGetNextPhase:
"""Benchmark ``Plan.get_next_phase()``."""
def setup(self) -> None:
self.plan_action = _make_plan(PlanPhase.ACTION)
self.plan_strategize = _make_plan(PlanPhase.STRATEGIZE)
self.plan_execute = _make_plan(PlanPhase.EXECUTE)
self.plan_apply = _make_plan(PlanPhase.APPLY)
def time_next_from_action(self) -> None:
self.plan_action.get_next_phase()
def time_next_from_strategize(self) -> None:
self.plan_strategize.get_next_phase()
def time_next_from_execute(self) -> None:
self.plan_execute.get_next_phase()
def time_next_from_apply(self) -> None:
self.plan_apply.get_next_phase()
class TimeIsTerminal:
"""Benchmark ``Plan.is_terminal`` property."""
def setup(self) -> None:
self.plan_queued = _make_plan(PlanPhase.STRATEGIZE, ProcessingState.QUEUED)
self.plan_applied = _make_plan(PlanPhase.APPLY, ProcessingState.APPLIED)
self.plan_constrained = _make_plan(PlanPhase.APPLY, ProcessingState.CONSTRAINED)
self.plan_cancelled = _make_plan(
PlanPhase.STRATEGIZE, ProcessingState.CANCELLED
)
def time_non_terminal(self) -> None:
self.plan_queued.is_terminal
def time_applied_terminal(self) -> None:
self.plan_applied.is_terminal
def time_constrained_terminal(self) -> None:
self.plan_constrained.is_terminal
def time_cancelled_terminal(self) -> None:
self.plan_cancelled.is_terminal
class TimeCanTransitionToNextPhase:
"""Benchmark ``Plan.can_transition_to_next_phase`` property."""
def setup(self) -> None:
self.plan_action = _make_plan(PlanPhase.ACTION, ProcessingState.QUEUED)
self.plan_strat_complete = _make_plan(
PlanPhase.STRATEGIZE, ProcessingState.COMPLETE
)
self.plan_apply = _make_plan(PlanPhase.APPLY, ProcessingState.QUEUED)
def time_action_can_transition(self) -> None:
self.plan_action.can_transition_to_next_phase
def time_strategize_complete_can_transition(self) -> None:
self.plan_strat_complete.can_transition_to_next_phase
def time_apply_cannot_transition(self) -> None:
self.plan_apply.can_transition_to_next_phase
+116
View File
@@ -0,0 +1,116 @@
# Plan Lifecycle Service Reference
> Source: `src/cleveragents/application/services/plan_lifecycle_service.py`
## Overview
The `PlanLifecycleService` manages the v3 plan lifecycle:
```
Action -> Strategize -> Execute -> Apply
```
Plans are instantiated from Action templates. Terminal outcomes live in the Apply phase's processing state: `applied`, `constrained`, `errored`, or `cancelled`.
## Action Management
| Method | Description |
|--------|-------------|
| `create_action(...)` | Create a new action (defaults to `available` state) |
| `get_action(name)` | Get action by namespaced name |
| `get_action_by_name(name)` | Alias for `get_action` with partial name resolution |
| `list_actions(namespace, state)` | List actions with optional filtering |
| `make_action_available(name)` | No-op (actions are available by default) |
| `archive_action(name)` | Set action state to `archived` |
## Plan Creation
### `use_action(action_name, project_links, arguments, ...)`
Uses an action on projects to create a plan in **Strategize** phase.
- Validates action is in `available` state
- Validates required arguments
- Resolves automation level (explicit > global default)
- Merges invariants (plan > action)
- Creates plan with `phase=STRATEGIZE, processing_state=QUEUED`
- Archives non-reusable actions after use
## Phase Transitions
| Method | From | To | Description |
|--------|------|----|-------------|
| `execute_plan(plan_id)` | Strategize/COMPLETE | Execute/QUEUED | Transition to Execute phase |
| `apply_plan(plan_id)` | Execute/COMPLETE | Apply/QUEUED | Transition to Apply phase |
## Processing State Methods
### Strategize Phase
| Method | State Transition | Description |
|--------|-----------------|-------------|
| `start_strategize(plan_id)` | QUEUED -> PROCESSING | Begin strategize work |
| `complete_strategize(plan_id)` | PROCESSING -> COMPLETE | Finish strategize, may auto-progress |
| `fail_strategize(plan_id, error)` | * -> ERRORED | Mark strategize as failed |
### Execute Phase
| Method | State Transition | Description |
|--------|-----------------|-------------|
| `start_execute(plan_id)` | QUEUED -> PROCESSING | Begin execute work |
| `complete_execute(plan_id)` | PROCESSING -> COMPLETE | Finish execute, may auto-progress |
| `fail_execute(plan_id, error)` | * -> ERRORED | Mark execute as failed |
### Apply Phase
| Method | State Transition | Description |
|--------|-----------------|-------------|
| `start_apply(plan_id)` | QUEUED -> PROCESSING | Begin apply work |
| `complete_apply(plan_id)` | PROCESSING -> APPLIED | **Terminal success** — changes committed |
| `constrain_apply(plan_id, reason)` | * -> CONSTRAINED | Cannot complete within constraints; may revert to Strategize |
| `fail_apply(plan_id, error)` | * -> ERRORED | Mark apply as failed |
### Apply Terminal Outcomes
The Apply phase has four terminal outcomes, modeled as `ProcessingState` values:
| Processing State | Meaning | Next Step |
|-----------------|---------|-----------|
| `APPLIED` | Success — changes committed | Done |
| `CONSTRAINED` | Cannot proceed within strategy constraints | May revert to Strategize |
| `ERRORED` | Failed | May retry or abandon |
| `CANCELLED` | User/system cancelled | Done |
## Cancellation
### `cancel_plan(plan_id, reason=None)`
Cancel any non-terminal plan. Sets `processing_state=CANCELLED`.
## Automation
### `should_auto_progress(plan)`
Pure query — checks if the plan should automatically advance:
- Strategize/COMPLETE with `REVIEW_BEFORE_APPLY` or `FULL_AUTOMATION`: auto-execute
- Execute/COMPLETE with `FULL_AUTOMATION`: auto-apply
### `auto_progress(plan_id)`
Idempotent — advances to next phase if automation permits.
### `pause_plan(plan_id)` / `resume_plan(plan_id, level)`
Pause halts auto-progression (sets MANUAL). Resume restores level and triggers immediate auto-progress if ready.
### `set_plan_automation_level(plan_id, level)`
Change automation level for non-terminal plans.
## Custom Exceptions
| Exception | Description |
|-----------|-------------|
| `InvalidPhaseTransitionError` | Attempted invalid phase transition |
| `ActionNotAvailableError` | Action not in `available` state |
| `PlanNotReadyError` | Plan not in expected state for transition |
+4 -4
View File
@@ -213,11 +213,11 @@ Feature: Plan Lifecycle Service
When I try to lifecycle apply the plan
Then a plan not ready error should be raised
Scenario: Complete apply transitions to Applied (terminal)
Scenario: Complete apply transitions to applied processing state (terminal)
Given I have a plan in apply phase with processing state
When I complete apply
Then the lifecycle plan phase should be "applied"
And the lifecycle plan processing state should be "complete"
Then the lifecycle plan phase should be "apply"
And the lifecycle plan processing state should be "applied"
And the applied timestamp should be set
And the plan should be terminal
@@ -269,7 +269,7 @@ Feature: Plan Lifecycle Service
And the lifecycle plan error message should be "User requested cancellation"
Scenario: Cannot cancel terminal plan
Given I have a plan in applied phase
Given I have a plan in terminal applied state
When I try to cancel the plan
Then a plan error should be raised
+9 -10
View File
@@ -46,7 +46,7 @@ Feature: Plan Domain Model
# PlanPhase Tests
Scenario: Plan phases are in correct order
Then the plan phases should be in order "strategize, execute, apply, applied"
Then the plan phases should be in order "action, strategize, execute, apply"
Scenario: STRATEGIZE phase is the starting phase
Given I create a new plan
@@ -111,10 +111,9 @@ Feature: Plan Domain Model
Given I create a new plan
Then the next phase should be "execute"
Scenario: Get next phase from APPLIED is None (terminal)
Given I create a plan in applied phase
Scenario: Get next phase from APPLY is None (terminal phase)
Given I create a plan in apply phase as terminal
Then the next phase should be empty
And the plan should be terminal
Scenario: Unknown phase returns no next phase
Given I create a plan with an unknown phase value
@@ -128,14 +127,14 @@ Feature: Plan Domain Model
Scenario: Valid transition from EXECUTE to APPLY
Then transition from "execute" to "apply" should be valid
Scenario: Valid transition from APPLY to APPLIED
Then transition from "apply" to "applied" should be valid
Scenario: Valid transition from APPLY to STRATEGIZE (revert via constrained)
Then transition from "apply" to "strategize" should be valid
Scenario: Invalid transition from STRATEGIZE to APPLY
Then transition from "strategize" to "apply" should be invalid
Scenario: No transition from APPLIED (terminal)
Then transition from "applied" to "strategize" should be invalid
Scenario: Valid transition from ACTION to STRATEGIZE
Then transition from "action" to "strategize" should be valid
# Error State Tests
@@ -165,6 +164,6 @@ Feature: Plan Domain Model
When I try to create a plan with invalid phase value
Then a validation error should be raised
Scenario: Applied phase cannot transition to next phase
Given I create a plan in applied phase
Scenario: Apply phase cannot transition to next phase
Given I create a plan in apply phase as terminal
Then the plan cannot transition to next phase
+5 -4
View File
@@ -38,11 +38,12 @@ Feature: Plan Model Coverage — New Fields and Helpers
When I create a project link with alias "api-svc"
Then the project link alias should be "api-svc"
# Plan — APPLIED phase auto-corrects processing_state
# Plan — APPLY phase with APPLIED processing_state is terminal
Scenario: APPLIED phase auto-corrects processing_state to COMPLETE
When I create a plan in applied phase with queued processing state
Then the plan processing state should be "complete"
Scenario: APPLY phase with APPLIED processing state is terminal
When I create a plan in apply phase with applied processing state
Then the plan processing state should be "applied"
And the plan should be terminal
# Plan — duplicate project link alias uniqueness
+10 -1
View File
@@ -506,7 +506,16 @@ def step_create_plan_apply_processing(context: Context) -> None:
@given("I have a plan in applied phase")
def step_create_plan_applied(context: Context) -> None:
"""Create a plan in applied (terminal) phase."""
"""Create a plan in apply phase with applied processing state (terminal)."""
step_create_plan_apply_processing(context)
context.plan = context.lifecycle_service.complete_apply(
context.plan.identity.plan_id
)
@given("I have a plan in terminal applied state")
def step_create_plan_terminal_applied(context: Context) -> None:
"""Create a plan that has reached the applied terminal state."""
step_create_plan_apply_processing(context)
context.plan = context.lifecycle_service.complete_apply(
context.plan.identity.plan_id
+6 -6
View File
@@ -104,15 +104,15 @@ def step_check_project_link_alias(context: Context, expected: str) -> None:
)
# ---- APPLIED phase auto-corrects processing_state ----
# ---- APPLY phase with APPLIED processing_state is terminal ----
@when("I create a plan in applied phase with queued processing state")
def step_create_applied_plan_queued(context: Context) -> None:
"""Create a plan in APPLIED phase with QUEUED (should auto-correct to COMPLETE)."""
@when("I create a plan in apply phase with applied processing state")
def step_create_apply_phase_applied_state(context: Context) -> None:
"""Create a plan in APPLY phase with APPLIED processing state (terminal)."""
context.plan = _base_plan(
phase=PlanPhase.APPLIED,
processing_state=ProcessingState.QUEUED,
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
+16 -7
View File
@@ -226,10 +226,19 @@ def step_create_plan_strategize_processing(context: Context) -> None:
@given("I create a plan in applied phase")
def step_create_plan_applied_phase(context: Context) -> None:
"""Create a plan in APPLIED phase."""
"""Create a plan in APPLY phase with APPLIED processing state (terminal)."""
context.plan = _default_plan(
phase=PlanPhase.APPLIED,
processing_state=ProcessingState.COMPLETE,
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
@given("I create a plan in apply phase as terminal")
def step_create_plan_apply_terminal(context: Context) -> None:
"""Create a plan in APPLY phase with APPLIED processing state (terminal)."""
context.plan = _default_plan(
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
@@ -271,10 +280,10 @@ def step_create_plan_strategize_cancelled(context: Context) -> None:
@given("I create a plan in action phase with archived state")
def step_create_plan_action_archived(context: Context) -> None:
"""Create a plan in APPLIED phase with COMPLETE state (formerly ACTION/ARCHIVED)."""
"""Create a plan in APPLY phase with APPLIED state (terminal, formerly ACTION/ARCHIVED)."""
context.plan = _default_plan(
phase=PlanPhase.APPLIED,
processing_state=ProcessingState.COMPLETE,
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
@@ -518,7 +527,7 @@ def step_try_create_plan_with_invalid_phase(context: Context) -> None:
description="Test plan",
action_name="local/test-action",
definition_of_done=None,
phase="action", # Invalid phase value
phase="nonexistent", # Invalid phase value
processing_state=ProcessingState.QUEUED,
strategy_actor=None,
execution_actor=None,
+21 -21
View File
@@ -1438,27 +1438,27 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
**Parallel Group A2c: Plan Phase + Apply State Rebaseline (M1-critical)**
**PARALLEL SUBTRACK A2c.alpha [Jeff]**: Plan phase/state alignment + CLI output updates
**SEQUENTIAL MERGE NOTE**: A2c.alpha must land before A5 persistence wiring and D0 apply integration.
- [ ] **COMMIT (Owner: Jeff | Group: A2c.alpha | Branch: feature/m1-plan-phase-rebaseline | Planned: Day 7 | Expected: Day 8) - Commit message: "feat(domain): rebaseline plan phases and apply states"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m1-plan-phase-rebaseline`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Jeff]: Update `PlanPhase` to include `ACTION` and remove `APPLIED` as a phase; keep phases strictly `action/strategize/execute/apply` per spec.
- [ ] Code [Jeff]: Expand `processing_state` (or introduce `apply_state` mapping) to represent Apply terminal outcomes (`applied`, `constrained`, `errored`, `cancelled`) and keep non-Apply states (`queued`, `processing`, `complete`) for Strategize/Execute.
- [ ] Code [Jeff]: Update `Plan` model validators to allow namespaced names for top-level plans and ULID-only identifiers for subplans; enforce ULID-only for subplans in hierarchy validators.
- [ ] Code [Jeff]: Update `Plan.as_cli_dict()` + CLI formatting to show Action phase, apply terminal outcome (explicit field), and phase/processing_state alignment.
- [ ] Code [Jeff]: Update `PlanLifecycleService` to create plans in Action phase, transition to Strategize on `plan use`, and set Apply terminal outcomes on `plan apply` (including `constrained` for spec-defined reversion cases).
- [ ] Docs [Jeff]: Update `docs/reference/plan_model.md` and `docs/reference/plan_lifecycle_service.md` with Action phase semantics, apply terminal outcomes, and reversion rules (Execute/Apply → Strategize).
- [ ] Tests (Behave) [Jeff]: Add/adjust scenarios for Action phase creation, Strategize entry, Apply terminal outcomes, and reversion preconditions.
- [ ] Tests (Robot) [Jeff]: Update lifecycle Robot suites to assert Action phase visibility and Apply terminal outcome fields in status output.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/plan_phase_bench.py` for phase transition validation overhead.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [ ] Git [Jeff]: `git add .`
- [ ] Git [Jeff]: `git commit -m "feat(domain): rebaseline plan phases and apply states"`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m1-plan-phase-rebaseline` to `master` with description "Rebaseline plan phases and apply terminal states to spec, with CLI output updates and tests.".
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git branch -d feature/m1-plan-phase-rebaseline`
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] **COMMIT (Owner: Jeff | Group: A2c.alpha | Branch: feature/m1-plan-phase-rebaseline | Planned: Day 7 | Expected: Day 8) - Commit message: "feat(domain): rebaseline plan phases and apply states"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m1-plan-phase-rebaseline`
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [X] Code [Jeff]: Update `PlanPhase` to include `ACTION` and remove `APPLIED` as a phase; keep phases strictly `action/strategize/execute/apply` per spec.
- [X] Code [Jeff]: Expand `processing_state` (or introduce `apply_state` mapping) to represent Apply terminal outcomes (`applied`, `constrained`, `errored`, `cancelled`) and keep non-Apply states (`queued`, `processing`, `complete`) for Strategize/Execute.
- [X] Code [Jeff]: Update `Plan` model validators to allow namespaced names for top-level plans and ULID-only identifiers for subplans; enforce ULID-only for subplans in hierarchy validators.
- [X] Code [Jeff]: Update `Plan.as_cli_dict()` + CLI formatting to show Action phase, apply terminal outcome (explicit field), and phase/processing_state alignment.
- [X] Code [Jeff]: Update `PlanLifecycleService` to create plans in Action phase, transition to Strategize on `plan use`, and set Apply terminal outcomes on `plan apply` (including `constrained` for spec-defined reversion cases).
- [X] Docs [Jeff]: Update `docs/reference/plan_model.md` and `docs/reference/plan_lifecycle_service.md` with Action phase semantics, apply terminal outcomes, and reversion rules (Execute/Apply → Strategize).
- [X] Tests (Behave) [Jeff]: Add/adjust scenarios for Action phase creation, Strategize entry, Apply terminal outcomes, and reversion preconditions.
- [X] Tests (Robot) [Jeff]: Update lifecycle Robot suites to assert Action phase visibility and Apply terminal outcome fields in status output.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/plan_phase_bench.py` for phase transition validation overhead.
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [X] Git [Jeff]: `git add .`
- [X] Git [Jeff]: `git commit -m "feat(domain): rebaseline plan phases and apply states"`
- [X] Forgejo PR [Jeff]: Open PR from `feature/m1-plan-phase-rebaseline` to `master` with description "Rebaseline plan phases and apply terminal states to spec, with CLI output updates and tests.".
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git branch -d feature/m1-plan-phase-rebaseline`
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] **Stage A3: Plan State Machine** (Day 1-2) - COMPLETED 2026-02-05
- [X] Code: Implement plan lifecycle state machine
+9 -6
View File
@@ -100,7 +100,8 @@ def _lifecycle_full_cycle() -> None:
assert plan.state == ProcessingState.PROCESSING
plan = service.complete_apply(plan_id)
assert plan.phase == PlanPhase.APPLIED
assert plan.phase == PlanPhase.APPLY
assert plan.state == ProcessingState.APPLIED
assert plan.is_terminal
print("lifecycle-full-cycle-ok")
@@ -444,16 +445,18 @@ def _plan_with_subplans() -> None:
def _phase_transitions_model() -> None:
"""Integration test: verify can_transition function for all valid/invalid paths."""
# Valid transitions (no ACTION phase in new spec)
# Valid transitions (four-phase: ACTION -> STRATEGIZE -> EXECUTE -> APPLY)
assert can_transition(PlanPhase.ACTION, PlanPhase.STRATEGIZE)
assert can_transition(PlanPhase.STRATEGIZE, PlanPhase.EXECUTE)
assert can_transition(PlanPhase.EXECUTE, PlanPhase.APPLY)
assert can_transition(PlanPhase.APPLY, PlanPhase.APPLIED)
assert can_transition(PlanPhase.EXECUTE, PlanPhase.STRATEGIZE)
assert can_transition(PlanPhase.APPLY, PlanPhase.STRATEGIZE)
# Invalid transitions
assert not can_transition(PlanPhase.STRATEGIZE, PlanPhase.APPLIED)
assert not can_transition(PlanPhase.STRATEGIZE, PlanPhase.APPLY)
assert not can_transition(PlanPhase.APPLIED, PlanPhase.STRATEGIZE)
assert not can_transition(PlanPhase.EXECUTE, PlanPhase.STRATEGIZE)
assert not can_transition(PlanPhase.ACTION, PlanPhase.EXECUTE)
assert not can_transition(PlanPhase.ACTION, PlanPhase.APPLY)
assert not can_transition(PlanPhase.APPLY, PlanPhase.EXECUTE)
print("phase-transitions-ok")
+1 -1
View File
@@ -11,7 +11,7 @@ ${HELPER_SCRIPT} robot/helper_plan_lifecycle_v3.py
*** Test Cases ***
Plan Full Lifecycle Through All Phases
[Documentation] Verify Action->Strategize->Execute->Apply->Applied end-to-end via PlanLifecycleService
[Documentation] Verify Action->Strategize->Execute->Apply (terminal) end-to-end via PlanLifecycleService
[Tags] lifecycle plan action critical
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} lifecycle-full-cycle cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
+10 -10
View File
@@ -16,8 +16,8 @@ Tool Package Is Importable
Tool Context Creation Works
[Documentation] Verify ToolExecutionContext can be created
${result}= Run Process ${PYTHON} -c
... from cleveragents.tool import ToolExecutionContext; ctx = ToolExecutionContext(plan_id='test-plan'); print(f'plan={ctx.plan_id} changes={len(ctx.changes)} traces={len(ctx.traces)}')
${code}= Set Variable from cleveragents.tool import ToolExecutionContext; ctx = ToolExecutionContext(plan_id='test-plan'); print(f'plan={ctx.plan_id} changes={len(ctx.changes)} traces={len(ctx.traces)}')
${result}= Run Process ${PYTHON} -c ${code}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} plan=test-plan
Should Contain ${result.stdout} changes=0
@@ -25,32 +25,32 @@ Tool Context Creation Works
Cancellation Token Works
[Documentation] Verify CancellationToken cancel and check
${result}= Run Process ${PYTHON} -c
... from cleveragents.tool import CancellationToken; t = CancellationToken(); print(f'before={t.is_cancelled}'); t.cancel(); print(f'after={t.is_cancelled}')
${code}= Set Variable from cleveragents.tool import CancellationToken; t = CancellationToken(); print(f'before={t.is_cancelled}'); t.cancel(); print(f'after={t.is_cancelled}')
${result}= Run Process ${PYTHON} -c ${code}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} before=False
Should Contain ${result.stdout} after=True
Schema Validator Works
[Documentation] Verify JSON Schema validation
${result}= Run Process ${PYTHON} -c
... from cleveragents.tool import validate_tool_input, ToolSchemaValidationError; schema = {"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}; validate_tool_input({"x": "hi"}, schema); print('valid'); ok = False\ntry:\n validate_tool_input({"x": 1}, schema)\nexcept ToolSchemaValidationError:\n ok = True\nprint(f'invalid_caught={ok}')
${code}= Set Variable from cleveragents.tool import validate_tool_input, ToolSchemaValidationError; schema = {"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}; validate_tool_input({"x": "hi"}, schema); print('valid'); ok = False\ntry:\n validate_tool_input({"x": 1}, schema)\nexcept ToolSchemaValidationError:\n ok = True\nprint(f'invalid_caught={ok}')
${result}= Run Process ${PYTHON} -c ${code}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} valid
Should Contain ${result.stdout} invalid_caught=True
Tool Runtime Registration Works
[Documentation] Verify ToolRuntime register and list
${result}= Run Process ${PYTHON} -c
... from cleveragents.tool import ToolRuntime; from cleveragents.domain.models.core.tool import Tool, ToolSource, ToolCapability; rt = ToolRuntime(); t = Tool(name='test/read', description='Read', source=ToolSource.BUILTIN, capability=ToolCapability(read_only=True)); print(f'before={rt.list_tools()}'); rt.register_tool(t, None); print(f'after={rt.list_tools()}')
${code}= Set Variable from cleveragents.tool import ToolRuntime; from cleveragents.domain.models.core.tool import Tool, ToolSource, ToolCapability; rt = ToolRuntime(); t = Tool(name='test/read', description='Read', source=ToolSource.BUILTIN, capability=ToolCapability(read_only=True)); print(f'before={rt.list_tools()}'); rt.register_tool(t, None); print(f'after={rt.list_tools()}')
${result}= Run Process ${PYTHON} -c ${code}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} before=[]
Should Contain ${result.stdout} test/read
Lifecycle Cache Works
[Documentation] Verify ToolLifecycleCache put/get
${result}= Run Process ${PYTHON} -c
... from cleveragents.tool import ToolLifecycleCache; c = ToolLifecycleCache(); print(f'plans={c.plan_count}'); c.put('p1', 'tool/a', 'dummy'); print(f'plans_after={c.plan_count}'); r = c.get('p1', 'tool/a'); print(f'got={r}')
${code}= Set Variable from cleveragents.tool import ToolLifecycleCache; c = ToolLifecycleCache(); print(f'plans={c.plan_count}'); c.put('p1', 'tool/a', 'dummy'); print(f'plans_after={c.plan_count}'); r = c.get('p1', 'tool/a'); print(f'got={r}')
${result}= Run Process ${PYTHON} -c ${code}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} plans=0
Should Contain ${result.stdout} plans_after=1
@@ -1,10 +1,12 @@
"""Plan Lifecycle Service for CleverAgents v3.
This service manages the v3 three-phase plan lifecycle:
Strategize -> Execute -> Apply -> Applied (terminal)
This service manages the v3 four-phase plan lifecycle:
Action -> Strategize -> Execute -> Apply
Plans are instantiated from Action templates via ``agents plan use``.
The Action phase is modeled separately in the Action domain model.
Plans are instantiated from Action templates. The Action phase is the
non-processing template phase; ``plan use`` transitions to Strategize.
Terminal outcomes live in Apply's processing state: applied, constrained,
errored, or cancelled.
Based on docs/specification.md and implementation plan Stage A3.
"""
@@ -94,8 +96,9 @@ class PlanLifecycleService:
This service handles:
- Creating actions (reusable plan templates)
- Using actions on projects to create plans
- Transitioning plans through phases (Action -> Strategize -> Execute -> Apply)
- Using actions on projects to create plans (Action -> Strategize)
- Transitioning plans through phases (Strategize -> Execute -> Apply)
- Apply terminal outcomes: applied, constrained, errored, cancelled
- Validating phase transitions
"""
@@ -776,13 +779,16 @@ class PlanLifecycleService:
return plan
def complete_apply(self, plan_id: str) -> Plan:
"""Complete the Apply phase, transitioning to Applied (terminal).
"""Complete the Apply phase — terminal success.
Sets ``processing_state`` to ``APPLIED`` (the terminal success
state within the Apply phase). The phase remains ``APPLY``.
Args:
plan_id: The plan ULID
Returns:
The updated Plan in Applied state
The updated Plan with processing_state=APPLIED
"""
plan = self.get_plan(plan_id)
@@ -794,9 +800,8 @@ class PlanLifecycleService:
if plan.state != ProcessingState.PROCESSING:
raise PlanError(f"Plan {plan_id} is not processing (current: {plan.state})")
# Transition to terminal APPLIED state
plan.phase = PlanPhase.APPLIED
plan.processing_state = ProcessingState.COMPLETE
# Terminal success: phase stays APPLY, state becomes APPLIED
plan.processing_state = ProcessingState.APPLIED
plan.timestamps.applied_at = datetime.now()
plan.timestamps.updated_at = datetime.now()
@@ -808,6 +813,38 @@ class PlanLifecycleService:
return plan
def constrain_apply(self, plan_id: str, reason: str) -> Plan:
"""Mark the Apply phase as constrained.
The plan cannot complete within the current strategy constraints.
It may revert to Strategize for re-planning.
Args:
plan_id: The plan ULID
reason: Why the apply is constrained
Returns:
The updated Plan with processing_state=CONSTRAINED
"""
plan = self.get_plan(plan_id)
if plan.phase != PlanPhase.APPLY:
raise PlanError(
f"Plan {plan_id} is not in Apply phase (current: {plan.phase.value})"
)
plan.processing_state = ProcessingState.CONSTRAINED
plan.error_message = reason
plan.timestamps.updated_at = datetime.now()
self._logger.info(
"Plan apply constrained",
plan_id=plan_id,
reason=reason,
)
return plan
def fail_apply(self, plan_id: str, error_message: str) -> Plan:
"""Mark Apply phase as failed."""
plan = self.get_plan(plan_id)
+61 -28
View File
@@ -1,10 +1,15 @@
"""Plan domain model for CleverAgents.
This module implements the three-phase plan lifecycle:
Strategize -> Execute -> Apply -> Applied (terminal)
This module implements the four-phase plan lifecycle:
Action -> Strategize -> Execute -> Apply
Plans are instantiated from Action templates via ``agents plan use``.
The Action phase is modeled separately in the Action domain model.
Plans are instantiated from Action templates. The Action phase is
the non-processing template phase. When a plan is *used* (via
``agents plan use``) it transitions to Strategize.
Terminal outcomes live in the Apply phase's processing state:
``applied`` (success), ``constrained`` (cannot proceed within strategy
constraints), ``errored``, or ``cancelled``.
Based on docs/specification.md and ADR-004 (Pydantic Validation).
"""
@@ -26,28 +31,40 @@ class PlanPhase(StrEnum):
"""Phase of a plan in the v3 lifecycle.
Plans progress through phases in order:
Strategize -> Execute -> Apply -> Applied (terminal)
Action -> Strategize -> Execute -> Apply
The Action phase is modeled separately in the Action domain model.
Action is the non-processing template phase. Terminal outcomes
(applied, constrained, errored, cancelled) are modeled as
``ProcessingState`` values within the Apply phase, **not** as
separate phases.
"""
ACTION = "action"
STRATEGIZE = "strategize"
EXECUTE = "execute"
APPLY = "apply"
APPLIED = "applied"
class ProcessingState(StrEnum):
"""States for plans in Strategize/Execute/Apply phases.
These states indicate what is happening during processing.
Strategize / Execute phases use: queued, processing, errored,
complete, cancelled.
Apply phase uses: queued, processing, errored, applied,
constrained, cancelled (but NOT complete terminal success in
Apply is ``applied``).
"""
QUEUED = "queued" # Waiting for compute/worker
PROCESSING = "processing" # Currently running
ERRORED = "errored" # Failed; includes error metadata
COMPLETE = "complete" # Finished successfully
COMPLETE = "complete" # Finished successfully (Strategize/Execute)
CANCELLED = "cancelled" # User/system cancelled
APPLIED = "applied" # Apply terminal success — changes committed
CONSTRAINED = "constrained" # Apply cannot proceed within constraints
class AutomationLevel(StrEnum):
@@ -457,12 +474,12 @@ class Plan(BaseModel):
"""Domain model for a v3 plan.
A plan is the fundamental unit of orchestration in CleverAgents v3.
It follows a three-phase lifecycle: Strategize -> Execute -> Apply
It follows a four-phase lifecycle: Action -> Strategize -> Execute -> Apply
Plans are instantiated from Action templates via ``agents plan use``.
Key concepts:
- Phase: Which step of the lifecycle (Strategize, Execute, Apply, Applied)
- Phase: Which step of the lifecycle (Action, Strategize, Execute, Apply)
- Processing State: What is happening in that phase (queued, processing, etc.)
- Identity: ULID-based identification with parent/root for hierarchy
- Namespace: Scoping mechanism for ownership and discoverability
@@ -629,14 +646,12 @@ class Plan(BaseModel):
def validate_phase_state_consistency(self) -> Plan:
"""Ensure phase and state are consistent.
- APPLIED phase must have COMPLETE processing state.
- ACTION phase defaults to QUEUED if no state supplied.
- CANCELLED state is terminal for any phase.
- Apply phase accepts APPLIED / CONSTRAINED as terminal states.
"""
if (
self.phase == PlanPhase.APPLIED
and self.processing_state != ProcessingState.COMPLETE
):
self.processing_state = ProcessingState.COMPLETE
if self.phase == PlanPhase.ACTION and self.processing_state is None:
self.processing_state = ProcessingState.QUEUED
return self
@model_validator(mode="after")
@@ -654,10 +669,18 @@ class Plan(BaseModel):
@property
def is_terminal(self) -> bool:
"""Check if plan is in a terminal state."""
if self.phase == PlanPhase.APPLIED:
return True
return self.processing_state == ProcessingState.CANCELLED
"""Check if plan is in a terminal state.
Terminal when:
- processing_state is APPLIED (Apply success)
- processing_state is CANCELLED (any phase)
- processing_state is CONSTRAINED (Apply cannot proceed)
"""
return self.processing_state in (
ProcessingState.APPLIED,
ProcessingState.CANCELLED,
ProcessingState.CONSTRAINED,
)
@property
def is_errored(self) -> bool:
@@ -666,18 +689,28 @@ class Plan(BaseModel):
@property
def can_transition_to_next_phase(self) -> bool:
"""Check if plan can transition to the next phase."""
if self.phase == PlanPhase.APPLIED:
return False # Terminal
"""Check if plan can transition to the next phase.
- ACTION phase: can always transition (template -> Strategize).
- Strategize / Execute: must be COMPLETE.
- Apply: terminal phase no forward transition.
"""
if self.phase == PlanPhase.ACTION:
return True # Templates can always be used
if self.phase == PlanPhase.APPLY:
return False # Terminal phase
return self.processing_state == ProcessingState.COMPLETE
def get_next_phase(self) -> PlanPhase | None:
"""Get the next phase in the lifecycle, or None if terminal."""
"""Get the next phase in the lifecycle, or None if terminal.
Apply is the last phase; there is no phase after it.
"""
phase_order = [
PlanPhase.ACTION,
PlanPhase.STRATEGIZE,
PlanPhase.EXECUTE,
PlanPhase.APPLY,
PlanPhase.APPLIED,
]
try:
current_idx = phase_order.index(self.phase)
@@ -784,10 +817,10 @@ class Plan(BaseModel):
# Phase transition map for validation
VALID_PHASE_TRANSITIONS: dict[PlanPhase, list[PlanPhase]] = {
PlanPhase.ACTION: [PlanPhase.STRATEGIZE], # plan use
PlanPhase.STRATEGIZE: [PlanPhase.EXECUTE], # execute command
PlanPhase.EXECUTE: [PlanPhase.APPLY], # apply command
PlanPhase.APPLY: [PlanPhase.APPLIED], # successful apply
PlanPhase.APPLIED: [], # terminal
PlanPhase.EXECUTE: [PlanPhase.APPLY, PlanPhase.STRATEGIZE], # apply or revert
PlanPhase.APPLY: [PlanPhase.STRATEGIZE], # revert via constrained
}
@@ -485,8 +485,11 @@ class ActionArgumentModel(Base): # type: ignore[misc]
class LifecyclePlanModel(Base): # type: ignore[misc]
"""Database model for spec-aligned lifecycle plans.
A lifecycle plan follows the three-phase lifecycle:
``Strategize -> Execute -> Apply -> Applied (terminal)``
A lifecycle plan follows the four-phase lifecycle:
``Action -> Strategize -> Execute -> Apply``
Terminal outcomes are modeled as processing states within Apply:
``applied``, ``constrained``, ``errored``, ``cancelled``.
Plans are created from Action templates via ``agents plan use``.
Each plan references the action it was created from (by namespaced name),
+14 -19
View File
@@ -6,12 +6,6 @@ JSON Schema validation, per-plan activation caching, execution tracing,
and cancellation propagation (C5.lifecycle).
"""
# C0.runtime — core tool spec, runner, registry
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.runtime import ToolError, ToolResult, ToolSpec
# C5.lifecycle — four-stage lifecycle runtime
from cleveragents.tool.context import (
BoundResource,
CancellationToken,
@@ -31,10 +25,15 @@ from cleveragents.tool.lifecycle import (
ToolInstance,
ToolLifecycleCache,
ToolNotActivatedError,
ToolResult as LifecycleToolResult,
ToolRuntime,
ToolRuntimeError,
)
from cleveragents.tool.lifecycle import (
ToolResult as LifecycleToolResult,
)
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner
from cleveragents.tool.runtime import ToolError, ToolResult, ToolSpec
from cleveragents.tool.schema_validator import (
ToolSchemaValidationError,
validate_tool_input,
@@ -42,35 +41,31 @@ from cleveragents.tool.schema_validator import (
)
__all__ = [
# C0.runtime
"ToolError",
"ToolRegistry",
"ToolResult",
"ToolRunner",
"ToolSpec",
# C5.lifecycle — context
"BoundResource",
"CancellationToken",
"Change",
"ChangeOperation",
"ToolCancelledError",
"ToolExecutionContext",
"ToolExecutionTrace",
# C5.lifecycle — runtime
"LifecycleToolResult",
"ToolAccessDeniedError",
"ToolActivationError",
"ToolCancelledError",
"ToolCheckpointRequiredError",
"ToolDeactivationError",
"ToolDescriptor",
"ToolError",
"ToolExecutionContext",
"ToolExecutionError",
"ToolExecutionTrace",
"ToolInstance",
"ToolLifecycleCache",
"ToolNotActivatedError",
"ToolRegistry",
"ToolResult",
"ToolRunner",
"ToolRuntime",
"ToolRuntimeError",
# C5.lifecycle — schema validation
"ToolSchemaValidationError",
"ToolSpec",
"validate_tool_input",
"validate_tool_output",
]