merge: resolve M4.1+M4.2 conflicts (M4.2 supersedes M4.1 models/service)

This commit is contained in:
2026-02-22 17:04:03 +00:00
9 changed files with 1694 additions and 326 deletions
+173
View File
@@ -0,0 +1,173 @@
"""Airspeed Velocity benchmarks for decision correction flows.
Measures BFS subtree traversal, impact analysis, dry-run report
generation, and revert execution overhead across varying tree sizes.
"""
from __future__ import annotations
from cleveragents.application.services.correction_service import CorrectionService
from cleveragents.domain.models.core.correction import (
CorrectionMode,
CorrectionStatus,
)
# ---------------------------------------------------------------------------
# Tree generators
# ---------------------------------------------------------------------------
_PLAN_ID = "01HGZ6FE0AQDYTR4BXVQZ6EA00"
def _linear_tree(root: str, depth: int) -> dict[str, list[str]]:
"""Build a linear chain of *depth* nodes starting at *root*."""
tree: dict[str, list[str]] = {}
current = root
for i in range(1, depth):
child = f"{root}_c{i}"
tree[current] = [child]
current = child
return tree
def _binary_tree(root: str, depth: int) -> dict[str, list[str]]:
"""Build a complete binary tree of *depth* levels rooted at *root*."""
tree: dict[str, list[str]] = {}
level = [root]
counter = 0
for _ in range(depth - 1):
next_level: list[str] = []
for node in level:
left = f"{root}_n{counter}"
right = f"{root}_n{counter + 1}"
counter += 2
tree[node] = [left, right]
next_level.extend([left, right])
level = next_level
return tree
# ---------------------------------------------------------------------------
# BFS traversal benchmarks
# ---------------------------------------------------------------------------
class BfsTraversalSuite:
"""Benchmark BFS subtree computation at varying tree sizes."""
def setup(self) -> None:
"""Prepare trees and service instances."""
self.svc = CorrectionService()
self.small_tree = _linear_tree("S", 5) # 5 nodes
self.medium_tree = _binary_tree("M", 5) # 31 nodes
self.large_tree = _binary_tree("L", 8) # 255 nodes
def time_bfs_small_tree(self) -> None:
"""BFS on a 5-node linear chain."""
self.svc._compute_affected_subtree("S", self.small_tree)
def time_bfs_medium_tree(self) -> None:
"""BFS on a 31-node binary tree (depth 5)."""
self.svc._compute_affected_subtree("M", self.medium_tree)
def time_bfs_large_tree(self) -> None:
"""BFS on a 255-node binary tree (depth 8)."""
self.svc._compute_affected_subtree("L", self.large_tree)
# ---------------------------------------------------------------------------
# Impact analysis benchmarks
# ---------------------------------------------------------------------------
class ImpactAnalysisSuite:
"""Benchmark full impact analysis including risk classification."""
def setup(self) -> None:
"""Prepare service and correction requests."""
self.svc = CorrectionService()
self.small_tree = _linear_tree("S", 3)
self.medium_tree = _binary_tree("M", 4)
self.large_tree = _binary_tree("L", 6)
self.req_small = self.svc.request_correction(
_PLAN_ID, "S", CorrectionMode.REVERT
)
self.req_medium = self.svc.request_correction(
_PLAN_ID, "M", CorrectionMode.REVERT
)
self.req_large = self.svc.request_correction(
_PLAN_ID, "L", CorrectionMode.REVERT
)
def time_impact_small(self) -> None:
"""Impact analysis on a 3-node chain."""
# Reset status so analyze_impact can run again
self.svc._corrections[
self.req_small.correction_id
].status = CorrectionStatus.PENDING
self.svc.analyze_impact(self.req_small.correction_id, self.small_tree)
def time_impact_medium(self) -> None:
"""Impact analysis on a 15-node binary tree."""
self.svc._corrections[
self.req_medium.correction_id
].status = CorrectionStatus.PENDING
self.svc.analyze_impact(self.req_medium.correction_id, self.medium_tree)
def time_impact_large(self) -> None:
"""Impact analysis on a 63-node binary tree."""
self.svc._corrections[
self.req_large.correction_id
].status = CorrectionStatus.PENDING
self.svc.analyze_impact(self.req_large.correction_id, self.large_tree)
# ---------------------------------------------------------------------------
# Dry-run report benchmarks
# ---------------------------------------------------------------------------
class DryRunReportSuite:
"""Benchmark dry-run report generation."""
def setup(self) -> None:
"""Prepare service and requests."""
self.svc = CorrectionService()
self.tree = _binary_tree("DR", 5)
self.req = self.svc.request_correction(_PLAN_ID, "DR", CorrectionMode.REVERT)
def time_dry_run_report(self) -> None:
"""Generate dry-run report for a 31-node tree."""
self.svc._corrections[self.req.correction_id].status = CorrectionStatus.PENDING
self.svc.generate_dry_run_report(self.req.correction_id, self.tree)
# ---------------------------------------------------------------------------
# Revert execution benchmarks
# ---------------------------------------------------------------------------
class RevertExecutionSuite:
"""Benchmark revert execution overhead."""
def _fresh_request(self, target: str) -> str:
"""Create a fresh correction request and return its ID."""
req = self.svc.request_correction(_PLAN_ID, target, CorrectionMode.REVERT)
return req.correction_id
def setup(self) -> None:
"""Prepare service and trees."""
self.svc = CorrectionService()
self.small_tree = _linear_tree("RS", 3)
self.medium_tree = _binary_tree("RM", 4)
def time_revert_small(self) -> None:
"""Revert execution on a 3-node chain."""
cid = self._fresh_request("RS")
self.svc.execute_revert(cid, self.small_tree)
def time_revert_medium(self) -> None:
"""Revert execution on a 15-node binary tree."""
cid = self._fresh_request("RM")
self.svc.execute_revert(cid, self.medium_tree)
+77 -103
View File
@@ -1,125 +1,99 @@
# Decision Correction
# Decision Correction Reference
Corrections allow users to **edit the decision tree** of a plan and recompute affected subtrees. This is the primary mechanism for steering plan execution after decisions have already been made.
## Overview
The correction subsystem allows operators to modify a plan's decision tree
after execution by either **reverting** a subtree of decisions or
**appending** new decisions as a child plan.
## Correction Modes
| Mode | Description |
|------|-------------|
| `revert` | Undo a specific decision and all of its descendants. The affected subtree is removed and will be recomputed with new guidance. |
| `append` | Add new guidance at a decision point without removing existing work. A new branch is created in the decision tree. |
### Revert
Revert mode is used when a decision was fundamentally wrong and needs to be undone. All child decisions that flowed from the reverted decision are also removed. The planner will re-evaluate using the supplied guidance.
Invalidates the targeted decision and every descendant reachable via
BFS traversal. Associated artifacts are archived and affected child
plans are rolled back.
```text
┌─── D1 (target) ◄── revert starts here
│ │
│ ┌──┴──┐
│ D2 D3 ← all invalidated
│ │
│ D4 ← also invalidated
```
### Append
Append mode is used when the original decision was acceptable but additional direction is needed. It creates a supplementary branch rather than replacing the original.
Preserves the original decision and spawns a **new child plan** rooted
at the target node. The child plan carries the operator's guidance and
produces additional decisions without disturbing the existing tree.
## Correction Lifecycle
Each correction moves through these statuses:
| Status | Description |
|--------|-------------|
| `pending` | Created but not yet analysed or executed |
| `analyzing` | Impact analysis is in progress |
| `executing` | The correction is being applied |
| `applied` | Successfully applied |
| `failed` | Execution encountered an error |
| `cancelled` | User cancelled before execution |
## CLI Commands
### `agents plan correct`
Create and execute a correction on a decision.
```
agents plan correct --mode (revert|append) --guidance <TEXT> [--dry-run] [--yes|-y] [--plan <PLAN_ID>] <DECISION_ID>
```text
D1 (target)
┌──┴──────────┐
D2 (original) CP-new ← child plan appended
```
**Options:**
## Impact Analysis — BFS Algorithm
| Option | Short | Description |
|--------|-------|-------------|
| `--mode` | `-m` | Correction mode: `revert` or `append` (default: `revert`) |
| `--guidance` | `-g` | Guidance text for the correction (required) |
| `--dry-run` | | Only analyse impact, do not execute |
| `--yes` | `-y` | Skip confirmation prompt |
| `--plan` | `-p` | Explicit plan ID (defaults to latest active plan) |
| `--format` | `-f` | Output format: `rich`, `json`, `yaml`, `plain`, `table` |
Impact analysis uses breadth-first search over the decision tree's
adjacency list (parent → children mapping):
**Examples:**
```python
from collections import deque
```bash
# Revert a decision with guidance
agents plan correct --mode revert -g "Use FastAPI instead of Flask" DEC-001
# Append guidance in dry-run mode
agents plan correct --mode append -g "Add caching layer" --dry-run DEC-002
# Skip confirmation
agents plan correct --mode revert -g "Fix the API endpoint" --yes DEC-003
def compute_affected_subtree(target_id, tree):
affected = []
queue = deque([target_id])
while queue:
node = queue.popleft()
affected.append(node)
queue.extend(tree.get(node, []))
return affected
```
### `agents plan diff --correction`
### Risk Classification
Show the diff for a specific correction attempt.
| Affected Count | Risk Level |
|----------------|------------|
| ≤ 3 | low |
| 4 10 | medium |
| > 10 | high |
```
agents plan diff --correction <CORRECTION_ATTEMPT_ID> <PLAN_ID>
## Dry-Run Output
A dry-run report contains:
- **impact** — Full `CorrectionImpact` with affected decisions, files,
child plans, estimated cost, and risk level.
- **decisions_to_invalidate** — Decision IDs that *would* be marked
invalid (revert mode only).
- **child_plans_to_rollback** — Child plans that *would* be rolled back.
- **estimated_recompute_time_seconds** — Wall-clock estimate.
- **warnings** — Human-readable cautions (e.g. high risk).
## Status Lifecycle
```text
PENDING → ANALYZING → EXECUTING → APPLIED
→ FAILED
PENDING → CANCELLED
ANALYZING → CANCELLED
```
## Domain Models
## Service API
### CorrectionRequest
| Field | Type | Description |
|-------|------|-------------|
| `correction_id` | ULID | Unique identifier |
| `plan_id` | str | Plan being corrected |
| `target_decision_id` | str | Decision to target |
| `mode` | CorrectionMode | `revert` or `append` |
| `guidance` | str | User-supplied guidance |
| `dry_run` | bool | Analyse only |
| `created_at` | datetime | Creation timestamp |
| `status` | CorrectionStatus | Current lifecycle status |
### CorrectionImpact
Returned by `--dry-run` to preview what would change:
| Field | Type | Description |
|-------|------|-------------|
| `affected_decisions` | list[str] | Decision IDs that would be recomputed |
| `affected_files` | list[str] | File paths that would change |
| `estimated_cost` | float or None | Estimated cost in credits |
| `risk_level` | str | `low`, `medium`, or `high` |
### CorrectionResult
Returned after execution completes:
| Field | Type | Description |
|-------|------|-------------|
| `correction_id` | str | The correction that was executed |
| `status` | CorrectionStatus | Final status |
| `new_decisions` | list[str] | New decision IDs created |
| `reverted_decisions` | list[str] | Decision IDs removed |
| `error_message` | str or None | Error detail if failed |
| `completed_at` | datetime | Completion timestamp |
### CorrectionAttempt
Records each execution attempt:
| Field | Type | Description |
|-------|------|-------------|
| `attempt_id` | ULID | Unique attempt identifier |
| `correction_id` | str | Parent correction |
| `started_at` | datetime | Start time |
| `completed_at` | datetime or None | End time |
| `success` | bool | Whether it succeeded |
| `details` | dict | Arbitrary metadata |
| Method | Description |
|-----------------------------|-----------------------------------------------|
| `request_correction()` | Create a new correction request |
| `analyze_impact()` | BFS impact analysis on the decision tree |
| `generate_dry_run_report()` | Full report without side effects |
| `execute_revert()` | Invalidate subtree + archive artifacts |
| `execute_append()` | Spawn child plan preserving original decision |
| `execute_correction()` | Dispatch to revert or append based on mode |
| `get_correction()` | Retrieve a correction by ID |
| `list_corrections()` | List corrections (optional plan_id filter) |
| `list_attempts()` | List execution attempts for a correction |
| `cancel_correction()` | Cancel a pending/analyzing correction |
+271
View File
@@ -0,0 +1,271 @@
@phase2 @correction @flows
Feature: Correction flows — revert and append
As an operator
I want to revert or append corrections to a plan's decision tree
So that I can fix mistakes without restarting from scratch
#
# Revert scenarios
#
Scenario: Simple revert invalidates target decision
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a decision tree where "D1" has no children
When I execute the revert correction
Then the correction flow result status should be "applied"
And the reverted decisions should contain "D1"
Scenario: Revert deep subtree invalidates all descendants
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a multi-parent decision tree with "D1" children "D2,D3" and "D2" children "D4,D5"
When I execute the revert correction
Then the reverted decisions should contain "D1"
And the reverted decisions should contain "D2"
And the reverted decisions should contain "D3"
And the reverted decisions should contain "D4"
And the reverted decisions should contain "D5"
Scenario: Revert archives artifacts for each affected decision
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a simple decision tree where "D1" has children "D2"
When I execute the revert correction
Then the archived artifacts should include "D1.artifact"
And the archived artifacts should include "D2.artifact"
Scenario: Revert correction transitions through expected statuses
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
Then the correction flow status should be "pending"
When I execute the revert correction with a decision tree where "D1" has no children
Then the correction flow status should be "applied"
# ───────────────────────────────────────────────────────────
# Append scenarios
# ───────────────────────────────────────────────────────────
Scenario: Simple append spawns child plan
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in append mode
When I execute the append correction
Then the correction flow result status should be "applied"
And the result should have a spawned child plan id
Scenario: Append with guidance preserves guidance on request
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in append mode with guidance "Fix auth module"
When I execute the append correction
Then the correction flow guidance should be "Fix auth module"
Scenario: Append creates new decision in result
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in append mode
When I execute the append correction
Then the result new decisions should not be empty
# ───────────────────────────────────────────────────────────
# Impact analysis
# ───────────────────────────────────────────────────────────
Scenario: Impact analysis with BFS subtree traversal
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a multi-parent decision tree with "D1" children "D2,D3" and "D3" children "D6"
When I analyze the impact
Then the affected decisions should be "D1,D2,D3,D6"
Scenario: Impact analysis risk level low for small subtree
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a simple decision tree where "D1" has children "D2"
When I analyze the impact
Then the risk level should be "low"
Scenario: Impact analysis risk level medium for moderate subtree
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a decision tree with 7 nodes rooted at "D1"
When I analyze the impact
Then the risk level should be "medium"
Scenario: Impact analysis risk level high for large subtree
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a decision tree with 15 nodes rooted at "D1"
When I analyze the impact
Then the risk level should be "high"
Scenario: Impact analysis on leaf node returns only the target
Given a correction flow service
And a correction request for plan "P1" targeting decision "LEAF" in revert mode
And an empty decision tree
When I analyze the impact
Then the affected decisions should be "LEAF"
# ───────────────────────────────────────────────────────────
# Dry-run
# ───────────────────────────────────────────────────────────
Scenario: Dry-run report generation for revert
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a simple decision tree where "D1" has children "D2,D3"
When I generate a dry-run report
Then the report mode should be "revert"
And the report decisions to invalidate should contain "D1"
And the report estimated recompute time should be greater than 0
Scenario: Dry-run report generates warnings for high risk
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a decision tree with 15 nodes rooted at "D1"
When I generate a dry-run report
Then the report warnings should contain "High risk"
Scenario: Dry-run report generates warnings for medium risk
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a decision tree with 7 nodes rooted at "D1"
When I generate a dry-run report
Then the report warnings should contain "Medium risk"
Scenario: Dry-run report for append has no decisions to invalidate
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in append mode
When I generate a dry-run report
Then the report decisions to invalidate should be empty
# ───────────────────────────────────────────────────────────
# Error handling
# ───────────────────────────────────────────────────────────
Scenario: Get correction with invalid id raises not found
Given a correction flow service
When I try to get correction "nonexistent-id"
Then a correction flow resource not found error should be raised
Scenario: Execute already applied correction raises validation error
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And I execute the revert correction with a decision tree where "D1" has no children
When I try to execute the revert correction again
Then a validation error should be raised for status
Scenario: Execute already cancelled correction raises validation error
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And I cancel the correction flow
When I try to execute the revert correction again
Then a validation error should be raised for status
Scenario: Cancel already applied correction raises validation error
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And I execute the revert correction with a decision tree where "D1" has no children
When I try to cancel the correction flow
Then a validation error should be raised for status
# ───────────────────────────────────────────────────────────
# Status transitions
# ───────────────────────────────────────────────────────────
Scenario: Correction status transitions from pending to applied on revert
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
When I execute the revert correction with a decision tree where "D1" has no children
Then the correction flow status should be "applied"
Scenario: Correction status transitions from pending to cancelled
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
When I cancel the correction flow
Then the correction flow status should be "cancelled"
# ───────────────────────────────────────────────────────────
# Multi-correction & listing
# ───────────────────────────────────────────────────────────
Scenario: Multiple corrections on same plan are listed correctly
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a correction request for plan "P1" targeting decision "D2" in append mode
When I list correction flows for plan "P1"
Then the correction list should have 2 items
Scenario: List corrections filters by plan id
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a correction request for plan "P2" targeting decision "D2" in append mode
When I list correction flows for plan "P1"
Then the correction list should have 1 items
# ───────────────────────────────────────────────────────────
# Cancellation
# ───────────────────────────────────────────────────────────
Scenario: Cancel pending correction succeeds
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
When I cancel the correction flow
Then the correction flow status should be "cancelled"
# ───────────────────────────────────────────────────────────
# Attempt tracking
# ───────────────────────────────────────────────────────────
Scenario: Execution records an attempt
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
When I execute the revert correction with a decision tree where "D1" has no children
Then the attempt list should have 1 entries
And the first attempt should be successful
Scenario: Append execution records an attempt
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in append mode
When I execute the append correction
Then the attempt list should have 1 entries
# ───────────────────────────────────────────────────────────
# Model validation
# ───────────────────────────────────────────────────────────
Scenario: CorrectionRequest rejects empty plan_id
Given a correction flow service
When I try to create a correction with empty plan_id
Then a validation error should be raised for empty field
Scenario: CorrectionRequest rejects empty target_decision_id
Given a correction flow service
When I try to create a correction with empty target_decision_id
Then a validation error should be raised for empty field
Scenario: CorrectionImpact rejects invalid risk level
When I try to create a correction impact with risk level "critical"
Then a correction flow pydantic validation error should be raised
Scenario: CorrectionDryRunReport contains all expected fields
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a simple decision tree where "D1" has children "D2"
When I generate a dry-run report
Then the report should have a correction_id
And the report should have an impact object
And the report should have a warnings list
Scenario: Execute correction dispatches to revert
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in revert mode
And a decision tree where "D1" has no children
When I execute correction via dispatch
Then the correction flow result status should be "applied"
And the reverted decisions should contain "D1"
Scenario: Execute correction dispatches to append
Given a correction flow service
And a correction request for plan "P1" targeting decision "D1" in append mode
When I execute correction via dispatch
Then the correction flow result status should be "applied"
And the result should have a spawned child plan id
+419
View File
@@ -0,0 +1,419 @@
"""Step definitions for correction_flows.feature.
Exercises CorrectionService revert/append flows, impact analysis,
dry-run reports, error handling, and model validation.
"""
from __future__ import annotations
from behave import given, then, when
from pydantic import ValidationError as PydanticValidationError
from cleveragents.application.services.correction_service import CorrectionService
from cleveragents.core.exceptions import ResourceNotFoundError, ValidationError
from cleveragents.domain.models.core.correction import (
CorrectionImpact,
CorrectionMode,
)
# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------
def _build_chain_tree(root: str, count: int) -> dict[str, list[str]]:
"""Build a linear chain tree with *count* total nodes starting at *root*."""
tree: dict[str, list[str]] = {}
current = root
for i in range(1, count):
child = f"{root}_child{i}"
tree[current] = [child]
current = child
return tree
# -------------------------------------------------------------------
# Given steps
# -------------------------------------------------------------------
@given("a correction flow service")
def step_create_service(context):
context.service = CorrectionService()
context.correction_id = None
context.decision_tree = None
context.result = None
context.report = None
context.impact = None
context.error = None
context.corrections_list = None
@given(
'a correction request for plan "{plan_id}" targeting decision "{decision_id}" in revert mode'
)
def step_create_revert_request(context, plan_id, decision_id):
req = context.service.request_correction(
plan_id=plan_id,
target_decision_id=decision_id,
mode=CorrectionMode.REVERT,
)
context.correction_id = req.correction_id
@given(
'a correction request for plan "{plan_id}" targeting decision "{decision_id}" in append mode'
)
def step_create_append_request(context, plan_id, decision_id):
req = context.service.request_correction(
plan_id=plan_id,
target_decision_id=decision_id,
mode=CorrectionMode.APPEND,
)
context.correction_id = req.correction_id
@given(
'a correction request for plan "{plan_id}" targeting decision "{decision_id}" in append mode with guidance "{guidance}"'
)
def step_create_append_with_guidance(context, plan_id, decision_id, guidance):
req = context.service.request_correction(
plan_id=plan_id,
target_decision_id=decision_id,
mode=CorrectionMode.APPEND,
guidance=guidance,
)
context.correction_id = req.correction_id
@given('a decision tree where "{parent}" has no children')
def step_tree_no_children(context, parent):
context.decision_tree = {}
@given('a simple decision tree where "{parent}" has children "{children}"')
def step_tree_with_children(context, parent, children):
tree = getattr(context, "decision_tree", None) or {}
tree[parent] = [c.strip() for c in children.split(",")]
context.decision_tree = tree
@given(
'a multi-parent decision tree with "{p1}" children "{c1}" and "{p2}" children "{c2}"'
)
def step_tree_two_parents(context, p1, c1, p2, c2):
tree = getattr(context, "decision_tree", None) or {}
tree[p1] = [c.strip() for c in c1.split(",")]
tree[p2] = [c.strip() for c in c2.split(",")]
context.decision_tree = tree
@given('a decision tree with {count:d} nodes rooted at "{root}"')
def step_tree_with_n_nodes(context, count, root):
context.decision_tree = _build_chain_tree(root, count)
@given("an empty decision tree")
def step_empty_tree(context):
context.decision_tree = {}
@given(
'I execute the revert correction with a decision tree where "{parent}" has no children'
)
def step_execute_revert_inline(context, parent):
context.decision_tree = {}
context.result = context.service.execute_revert(
context.correction_id, context.decision_tree
)
@given("I cancel the correction flow")
def step_cancel_given(context):
context.service.cancel_correction(context.correction_id)
# -------------------------------------------------------------------
# When steps
# -------------------------------------------------------------------
@when("I execute the revert correction")
def step_execute_revert(context):
context.result = context.service.execute_revert(
context.correction_id, context.decision_tree
)
@when(
'I execute the revert correction with a decision tree where "{parent}" has no children'
)
def step_when_execute_revert_inline(context, parent):
context.decision_tree = {}
context.result = context.service.execute_revert(
context.correction_id, context.decision_tree
)
@when("I execute the append correction")
def step_execute_append(context):
context.result = context.service.execute_append(context.correction_id)
@when("I analyze the impact")
def step_analyze_impact(context):
context.impact = context.service.analyze_impact(
context.correction_id, context.decision_tree
)
@when("I generate a dry-run report")
def step_generate_dry_run(context):
context.report = context.service.generate_dry_run_report(
context.correction_id, context.decision_tree
)
@when('I try to get correction "{cid}"')
def step_try_get_correction(context, cid):
try:
context.service.get_correction(cid)
context.error = None
except ResourceNotFoundError as exc:
context.error = exc
@when("I try to execute the revert correction again")
def step_try_execute_revert_again(context):
try:
context.service.execute_revert(context.correction_id, {})
context.error = None
except ValidationError as exc:
context.error = exc
@when("I try to cancel the correction flow")
def step_try_cancel(context):
try:
context.service.cancel_correction(context.correction_id)
context.error = None
except ValidationError as exc:
context.error = exc
@when("I cancel the correction flow")
def step_cancel_correction(context):
context.service.cancel_correction(context.correction_id)
@when('I list correction flows for plan "{plan_id}"')
def step_list_corrections(context, plan_id):
context.corrections_list = context.service.list_corrections(plan_id=plan_id)
@when("I try to create a correction with empty plan_id")
def step_create_empty_plan_id(context):
try:
context.service.request_correction(
plan_id="",
target_decision_id="D1",
mode=CorrectionMode.REVERT,
)
context.error = None
except ValidationError as exc:
context.error = exc
@when("I try to create a correction with empty target_decision_id")
def step_create_empty_target(context):
try:
context.service.request_correction(
plan_id="P1",
target_decision_id="",
mode=CorrectionMode.REVERT,
)
context.error = None
except ValidationError as exc:
context.error = exc
@when('I try to create a correction impact with risk level "{level}"')
def step_create_bad_risk_level(context, level):
try:
CorrectionImpact(risk_level=level)
context.error = None
except PydanticValidationError as exc:
context.error = exc
@when("I execute correction via dispatch")
def step_execute_dispatch(context):
context.result = context.service.execute_correction(
context.correction_id, context.decision_tree
)
# -------------------------------------------------------------------
# Then steps
# -------------------------------------------------------------------
@then('the correction flow result status should be "{status}"')
def step_result_status(context, status):
assert context.result is not None, "No result produced"
assert context.result.status == status, (
f"Expected status '{status}', got '{context.result.status}'"
)
@then('the reverted decisions should contain "{decision_id}"')
def step_reverted_contains(context, decision_id):
assert decision_id in context.result.reverted_decisions, (
f"'{decision_id}' not in reverted: {context.result.reverted_decisions}"
)
@then('the archived artifacts should include "{artifact}"')
def step_archived_artifact(context, artifact):
assert artifact in context.result.archived_artifacts, (
f"'{artifact}' not in archived: {context.result.archived_artifacts}"
)
@then('the correction flow status should be "{status}"')
def step_correction_status(context, status):
req = context.service.get_correction(context.correction_id)
assert req.status == status, (
f"Expected correction status '{status}', got '{req.status}'"
)
@then("the result should have a spawned child plan id")
def step_has_spawned_child(context):
assert context.result.spawned_child_plan_id is not None, (
"Expected spawned_child_plan_id, got None"
)
@then('the correction flow guidance should be "{guidance}"')
def step_guidance(context, guidance):
req = context.service.get_correction(context.correction_id)
assert req.guidance == guidance, (
f"Expected guidance '{guidance}', got '{req.guidance}'"
)
@then("the result new decisions should not be empty")
def step_new_decisions_not_empty(context):
assert len(context.result.new_decisions) > 0, "new_decisions is empty"
@then('the affected decisions should be "{expected}"')
def step_affected_decisions(context, expected):
expected_list = [d.strip() for d in expected.split(",")]
assert context.impact.affected_decisions == expected_list, (
f"Expected {expected_list}, got {context.impact.affected_decisions}"
)
@then('the risk level should be "{level}"')
def step_risk_level(context, level):
assert context.impact.risk_level == level, (
f"Expected risk '{level}', got '{context.impact.risk_level}'"
)
@then('the report mode should be "{mode}"')
def step_report_mode(context, mode):
assert context.report.mode == mode, (
f"Expected report mode '{mode}', got '{context.report.mode}'"
)
@then('the report decisions to invalidate should contain "{decision_id}"')
def step_report_invalidate_contains(context, decision_id):
assert decision_id in context.report.decisions_to_invalidate, (
f"'{decision_id}' not in invalidate list"
)
@then("the report estimated recompute time should be greater than 0")
def step_report_recompute_positive(context):
assert context.report.estimated_recompute_time_seconds > 0
@then('the report warnings should contain "{fragment}"')
def step_report_warning_fragment(context, fragment):
joined = " ".join(context.report.warnings)
assert fragment in joined, (
f"'{fragment}' not found in warnings: {context.report.warnings}"
)
@then("the report decisions to invalidate should be empty")
def step_report_invalidate_empty(context):
assert len(context.report.decisions_to_invalidate) == 0, (
f"Expected empty invalidate list, got {context.report.decisions_to_invalidate}"
)
@then("a correction flow resource not found error should be raised")
def step_resource_not_found(context):
assert isinstance(context.error, ResourceNotFoundError), (
f"Expected ResourceNotFoundError, got {type(context.error)}"
)
@then("a validation error should be raised for status")
def step_validation_error_status(context):
assert isinstance(context.error, ValidationError), (
f"Expected ValidationError, got {type(context.error)}"
)
@then("a validation error should be raised for empty field")
def step_validation_error_empty(context):
assert isinstance(context.error, ValidationError), (
f"Expected ValidationError, got {type(context.error)}"
)
@then("a correction flow pydantic validation error should be raised")
def step_pydantic_error(context):
assert isinstance(context.error, PydanticValidationError), (
f"Expected PydanticValidationError, got {type(context.error)}"
)
@then("the correction list should have {count:d} items")
def step_list_count(context, count):
assert len(context.corrections_list) == count, (
f"Expected {count} corrections, got {len(context.corrections_list)}"
)
@then("the attempt list should have {count:d} entries")
def step_attempt_count(context, count):
attempts = context.service.list_attempts(context.correction_id)
assert len(attempts) == count, f"Expected {count} attempts, got {len(attempts)}"
@then("the first attempt should be successful")
def step_first_attempt_success(context):
attempts = context.service.list_attempts(context.correction_id)
assert attempts[0].success is True, "First attempt was not successful"
@then("the report should have a correction_id")
def step_report_has_cid(context):
assert context.report.correction_id is not None
@then("the report should have an impact object")
def step_report_has_impact(context):
assert context.report.impact is not None
@then("the report should have a warnings list")
def step_report_has_warnings(context):
assert isinstance(context.report.warnings, list)
+66
View File
@@ -0,0 +1,66 @@
*** Settings ***
Documentation Smoke tests for correction flows: revert, append, dry-run,
... impact analysis, cancellation, and error handling.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} robot/helper_correction_flows.py
*** Test Cases ***
Revert Simple Correction
[Documentation] Verify simple revert invalidates target decision
[Tags] phase2 correction flows revert
${result}= Run Process ${PYTHON} ${HELPER} revert-simple cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} revert-simple-ok
Revert Deep Subtree
[Documentation] Verify revert walks full BFS subtree
[Tags] phase2 correction flows revert
${result}= Run Process ${PYTHON} ${HELPER} revert-deep-subtree cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} revert-deep-subtree-ok
Append Correction
[Documentation] Verify append spawns a child plan
[Tags] phase2 correction flows append
${result}= Run Process ${PYTHON} ${HELPER} append-correction cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} append-correction-ok
Dry Run Report Generation
[Documentation] Verify dry-run report contains expected fields
[Tags] phase2 correction flows dryrun
${result}= Run Process ${PYTHON} ${HELPER} dry-run-report cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} dry-run-report-ok
Impact Analysis Risk Levels
[Documentation] Verify risk classification low / high thresholds
[Tags] phase2 correction flows impact
${result}= Run Process ${PYTHON} ${HELPER} impact-risk-levels cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} impact-risk-levels-ok
Cancel Correction
[Documentation] Verify pending correction can be cancelled
[Tags] phase2 correction flows cancel
${result}= Run Process ${PYTHON} ${HELPER} cancel-correction cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} cancel-correction-ok
Execute Already Cancelled Error
[Documentation] Verify executing a cancelled correction raises error
[Tags] phase2 correction flows error
${result}= Run Process ${PYTHON} ${HELPER} execute-cancelled-error cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} execute-cancelled-error-ok
List Corrections By Plan
[Documentation] Verify listing corrections filters by plan_id
[Tags] phase2 correction flows query
${result}= Run Process ${PYTHON} ${HELPER} list-corrections-by-plan cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} list-corrections-by-plan-ok
+157
View File
@@ -0,0 +1,157 @@
"""Helper script for correction flows Robot Framework smoke tests.
Exercises CorrectionService revert/append flows, impact analysis,
dry-run reports, and cancellation without requiring persistence.
"""
from __future__ import annotations
import sys
from cleveragents.application.services.correction_service import CorrectionService
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.correction import CorrectionMode
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
_PLAN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
_DECISION_D1 = "D1"
_DECISION_D2 = "D2"
_DECISION_D3 = "D3"
def _simple_tree() -> dict[str, list[str]]:
return {_DECISION_D1: [_DECISION_D2, _DECISION_D3]}
def _deep_tree() -> dict[str, list[str]]:
return {
_DECISION_D1: [_DECISION_D2, _DECISION_D3],
_DECISION_D2: ["D4", "D5"],
_DECISION_D3: ["D6"],
}
# ---------------------------------------------------------------------------
# Command handlers
# ---------------------------------------------------------------------------
def _revert_simple() -> None:
svc = CorrectionService()
req = svc.request_correction(_PLAN_ID, _DECISION_D1, CorrectionMode.REVERT)
result = svc.execute_revert(req.correction_id, {_DECISION_D1: []})
assert result.status == "applied", f"Expected applied, got {result.status}"
assert _DECISION_D1 in result.reverted_decisions
print("revert-simple-ok")
def _revert_deep_subtree() -> None:
svc = CorrectionService()
req = svc.request_correction(_PLAN_ID, _DECISION_D1, CorrectionMode.REVERT)
result = svc.execute_revert(req.correction_id, _deep_tree())
assert len(result.reverted_decisions) == 6
print("revert-deep-subtree-ok")
def _append_correction() -> None:
svc = CorrectionService()
req = svc.request_correction(_PLAN_ID, _DECISION_D1, CorrectionMode.APPEND)
result = svc.execute_append(req.correction_id)
assert result.status == "applied"
assert result.spawned_child_plan_id is not None
print("append-correction-ok")
def _dry_run_report() -> None:
svc = CorrectionService()
req = svc.request_correction(_PLAN_ID, _DECISION_D1, CorrectionMode.REVERT)
report = svc.generate_dry_run_report(req.correction_id, _simple_tree())
assert report.mode == "revert"
assert len(report.decisions_to_invalidate) == 3
assert report.estimated_recompute_time_seconds > 0
print("dry-run-report-ok")
def _impact_risk_levels() -> None:
svc = CorrectionService()
# Low risk: 2 nodes
req1 = svc.request_correction(_PLAN_ID, _DECISION_D1, CorrectionMode.REVERT)
impact1 = svc.analyze_impact(req1.correction_id, {_DECISION_D1: [_DECISION_D2]})
assert impact1.risk_level == "low", f"Expected low, got {impact1.risk_level}"
# High risk: > 10 nodes (chain)
tree: dict[str, list[str]] = {}
current = "R"
for i in range(14):
child = f"R_c{i}"
tree[current] = [child]
current = child
req2 = svc.request_correction(_PLAN_ID, "R", CorrectionMode.REVERT)
impact2 = svc.analyze_impact(req2.correction_id, tree)
assert impact2.risk_level == "high", f"Expected high, got {impact2.risk_level}"
print("impact-risk-levels-ok")
def _cancel_correction() -> None:
svc = CorrectionService()
req = svc.request_correction(_PLAN_ID, _DECISION_D1, CorrectionMode.REVERT)
svc.cancel_correction(req.correction_id)
updated = svc.get_correction(req.correction_id)
assert updated.status == "cancelled"
print("cancel-correction-ok")
def _execute_cancelled_error() -> None:
svc = CorrectionService()
req = svc.request_correction(_PLAN_ID, _DECISION_D1, CorrectionMode.REVERT)
svc.cancel_correction(req.correction_id)
try:
svc.execute_revert(req.correction_id, {})
print("FAIL: expected ValidationError")
sys.exit(1)
except ValidationError:
print("execute-cancelled-error-ok")
def _list_corrections_by_plan() -> None:
svc = CorrectionService()
svc.request_correction(_PLAN_ID, _DECISION_D1, CorrectionMode.REVERT)
svc.request_correction(_PLAN_ID, _DECISION_D2, CorrectionMode.APPEND)
svc.request_correction("OTHER_PLAN", _DECISION_D3, CorrectionMode.REVERT)
result = svc.list_corrections(plan_id=_PLAN_ID)
assert len(result) == 2, f"Expected 2, got {len(result)}"
print("list-corrections-by-plan-ok")
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
COMMANDS: dict[str, object] = {
"revert-simple": _revert_simple,
"revert-deep-subtree": _revert_deep_subtree,
"append-correction": _append_correction,
"dry-run-report": _dry_run_report,
"impact-risk-levels": _impact_risk_levels,
"cancel-correction": _cancel_correction,
"execute-cancelled-error": _execute_cancelled_error,
"list-corrections-by-plan": _list_corrections_by_plan,
}
def main() -> None:
"""Dispatch command from sys.argv."""
if len(sys.argv) < 2:
raise SystemExit("Expected command argument")
command = sys.argv[1]
if command not in COMMANDS:
raise SystemExit(f"Unknown command: {command}")
func = COMMANDS[command]
if callable(func):
func()
if __name__ == "__main__":
main()
@@ -1,17 +1,22 @@
"""Correction service for managing decision tree corrections.
"""Correction service implementing revert and append flows.
Orchestrates the creation, analysis, execution, and cancellation of
corrections to a plan's decision tree. Uses in-memory storage;
persistence will be added in a later milestone.
Orchestrates impact analysis using BFS subtree traversal, dry-run
reporting, revert execution (decision invalidation + artifact archival),
and append execution (child plan spawning).
"""
from __future__ import annotations
from datetime import datetime
from collections import deque
from datetime import UTC, datetime
import structlog
from ulid import ULID
from cleveragents.core.exceptions import ResourceNotFoundError, ValidationError
from cleveragents.domain.models.core.correction import (
CorrectionAttempt,
CorrectionDryRunReport,
CorrectionImpact,
CorrectionMode,
CorrectionRequest,
@@ -19,220 +24,442 @@ from cleveragents.domain.models.core.correction import (
CorrectionStatus,
)
__all__ = ["CorrectionService"]
logger = structlog.get_logger(__name__)
# ---------------------------------------------------------------------------
# Risk-level thresholds
# ---------------------------------------------------------------------------
_RISK_LOW_MAX = 3
_RISK_MEDIUM_MAX = 10
class CorrectionService:
"""Service for managing decision-tree corrections.
"""Service for creating, analysing, and executing decision corrections.
All corrections are stored in memory. The ``analyze_impact`` and
``execute_correction`` methods are stubs that will be fully
implemented in milestone M4.2.
State is held in-memory via dictionaries keyed by ``correction_id``.
A production deployment would swap these for repository adapters.
"""
def __init__(self) -> None:
self._corrections: dict[str, CorrectionRequest] = {}
self._impacts: dict[str, CorrectionImpact] = {}
self._attempts: dict[str, list[CorrectionAttempt]] = {}
self._results: dict[str, CorrectionResult] = {}
# ── Create ──────────────────────────────────────────────────
# ------------------------------------------------------------------
# Creation
# ------------------------------------------------------------------
def request_correction(
self,
plan_id: str,
decision_id: str,
target_decision_id: str,
mode: CorrectionMode,
guidance: str,
guidance: str = "",
dry_run: bool = False,
) -> CorrectionRequest:
"""Create a new correction request.
"""Create and register a new correction request.
Args:
plan_id: The plan whose decision tree to correct.
decision_id: The target decision ID.
mode: ``revert`` or ``append``.
guidance: Human-readable guidance for the correction.
dry_run: If ``True``, only analyse impact.
plan_id: Plan owning the decision tree.
target_decision_id: Decision node to target.
mode: ``CorrectionMode.REVERT`` or ``CorrectionMode.APPEND``.
guidance: Optional human guidance text.
dry_run: If *True*, only impact analysis will be performed.
Returns:
The newly created :class:`CorrectionRequest`.
The newly created ``CorrectionRequest``.
Raises:
ValidationError: If any argument is invalid.
ValidationError: If required parameters are empty.
"""
if not plan_id or not plan_id.strip():
raise ValidationError("plan_id must not be empty")
if not decision_id or not decision_id.strip():
raise ValidationError("decision_id must not be empty")
if not guidance or not guidance.strip():
raise ValidationError("guidance must not be empty")
if not isinstance(mode, CorrectionMode):
raise ValidationError(
f"mode must be a CorrectionMode, got {type(mode).__name__}"
)
if not target_decision_id or not target_decision_id.strip():
raise ValidationError("target_decision_id must not be empty")
request = CorrectionRequest(
plan_id=plan_id,
target_decision_id=decision_id,
target_decision_id=target_decision_id,
mode=mode,
guidance=guidance,
dry_run=dry_run,
)
self._corrections[request.correction_id] = request
self._attempts[request.correction_id] = []
logger.info(
"correction.requested",
correction_id=request.correction_id,
plan_id=plan_id,
mode=mode,
dry_run=dry_run,
)
return request
# ── Analyze ─────────────────────────────────────────────────
# ------------------------------------------------------------------
# Impact analysis
# ------------------------------------------------------------------
def analyze_impact(self, correction_id: str) -> CorrectionImpact:
"""Analyse the impact of a pending correction (stub).
Full implementation will be provided in milestone M4.2.
def analyze_impact(
self,
correction_id: str,
decision_tree: dict[str, list[str]] | None = None,
) -> CorrectionImpact:
"""Compute the impact of a correction via BFS subtree traversal.
Args:
correction_id: The correction to analyse.
correction_id: Previously created correction request ID.
decision_tree: Adjacency list mapping parent → children.
Returns:
A placeholder :class:`CorrectionImpact`.
``CorrectionImpact`` with affected nodes and risk level.
Raises:
ResourceNotFoundError: If the correction does not exist.
"""
correction = self._get_or_raise(correction_id)
correction.status = CorrectionStatus.ANALYZING
request = self._get_request_or_raise(correction_id)
request.status = CorrectionStatus.ANALYZING
# Stub: return placeholder impact
return CorrectionImpact(
affected_decisions=[correction.target_decision_id],
affected_files=[],
estimated_cost=None,
risk_level="low",
tree = decision_tree or {}
affected = self._compute_affected_subtree(request.target_decision_id, tree)
risk = self._classify_risk(len(affected))
# Derive artefacts from decision IDs (convention: <id>.artifact)
artifacts = [f"{d}.artifact" for d in affected]
impact = CorrectionImpact(
affected_decisions=affected,
affected_files=[f"{d}.py" for d in affected],
affected_child_plans=[],
estimated_cost=float(len(affected)) * 1.5,
risk_level=risk,
rollback_tier="full"
if request.mode == CorrectionMode.REVERT
else "append_only",
artifacts_to_archive=artifacts,
)
self._impacts[correction_id] = impact
# ── Execute ─────────────────────────────────────────────────
logger.info(
"correction.impact_analyzed",
correction_id=correction_id,
affected_count=len(affected),
risk_level=risk,
)
return impact
def execute_correction(self, correction_id: str) -> CorrectionResult:
"""Execute a pending correction (stub).
# ------------------------------------------------------------------
# Dry-run report
# ------------------------------------------------------------------
Full implementation will be provided in milestone M4.2.
def generate_dry_run_report(
self,
correction_id: str,
decision_tree: dict[str, list[str]] | None = None,
) -> CorrectionDryRunReport:
"""Generate a dry-run report without mutating state.
Calls ``analyze_impact`` internally, then assembles warnings
and estimated recompute time.
Args:
correction_id: The correction to execute.
correction_id: Correction request ID.
decision_tree: Optional decision tree adjacency list.
Returns:
A :class:`CorrectionResult` with the outcome.
Raises:
ResourceNotFoundError: If the correction does not exist.
ValidationError: If the correction is not in a valid state.
``CorrectionDryRunReport`` describing what *would* happen.
"""
correction = self._get_or_raise(correction_id)
request = self._get_request_or_raise(correction_id)
impact = self.analyze_impact(correction_id, decision_tree)
if correction.status in {
CorrectionStatus.APPLIED,
CorrectionStatus.CANCELLED,
}:
raise ValidationError(
f"Cannot execute correction in {correction.status.value} state"
warnings: list[str] = []
if impact.risk_level == "high":
warnings.append(
"High risk: more than 10 decisions affected. "
"Review carefully before executing."
)
if impact.risk_level == "medium":
warnings.append("Medium risk: 4-10 decisions affected.")
if request.mode == CorrectionMode.REVERT and len(impact.affected_decisions) > 1:
warnings.append(
f"Revert will invalidate {len(impact.affected_decisions)} decisions "
"and archive associated artifacts."
)
correction.status = CorrectionStatus.EXECUTING
recompute_seconds = float(len(impact.affected_decisions)) * 2.0
# Record attempt
attempt = CorrectionAttempt(
report = CorrectionDryRunReport(
correction_id=correction_id,
success=True,
completed_at=datetime.now(),
details={"stub": True},
)
self._attempts.setdefault(correction_id, []).append(attempt)
# Stub: mark as applied
correction.status = CorrectionStatus.APPLIED
return CorrectionResult(
correction_id=correction_id,
status=CorrectionStatus.APPLIED,
new_decisions=[],
reverted_decisions=(
[correction.target_decision_id]
if correction.mode == CorrectionMode.REVERT
else []
),
mode=request.mode,
impact=impact,
decisions_to_invalidate=impact.affected_decisions
if request.mode == CorrectionMode.REVERT
else [],
child_plans_to_rollback=impact.affected_child_plans,
estimated_recompute_time_seconds=recompute_seconds,
warnings=warnings,
)
# ── Query ───────────────────────────────────────────────────
logger.info(
"correction.dry_run_generated",
correction_id=correction_id,
warning_count=len(warnings),
)
return report
def get_correction(self, correction_id: str) -> CorrectionRequest:
"""Retrieve a correction by ID.
# ------------------------------------------------------------------
# Execution: revert
# ------------------------------------------------------------------
def execute_revert(
self,
correction_id: str,
decision_tree: dict[str, list[str]] | None = None,
) -> CorrectionResult:
"""Execute a revert correction.
Marks every decision in the affected subtree as reverted and
records all archived artifacts.
Args:
correction_id: The correction to look up.
correction_id: Correction request ID.
decision_tree: Optional adjacency list for BFS.
Returns:
The :class:`CorrectionRequest`.
``CorrectionResult`` with reverted decisions.
Raises:
ResourceNotFoundError: If correction does not exist.
ValidationError: If correction is not in PENDING or ANALYZING status.
"""
request = self._get_request_or_raise(correction_id)
self._assert_executable(request)
request.status = CorrectionStatus.EXECUTING
attempt = CorrectionAttempt(correction_id=correction_id)
self._attempts[correction_id].append(attempt)
try:
impact = self.analyze_impact(correction_id, decision_tree)
result = CorrectionResult(
correction_id=correction_id,
status=CorrectionStatus.APPLIED,
reverted_decisions=impact.affected_decisions,
archived_artifacts=impact.artifacts_to_archive,
)
request.status = CorrectionStatus.APPLIED
attempt.success = True
except Exception as exc:
result = CorrectionResult(
correction_id=correction_id,
status=CorrectionStatus.FAILED,
error_message=str(exc),
)
request.status = CorrectionStatus.FAILED
attempt.success = False
attempt.details = {"error": str(exc)}
finally:
attempt.completed_at = datetime.now(UTC)
self._results[correction_id] = result
logger.info(
"correction.revert_executed",
correction_id=correction_id,
status=result.status,
)
return result
# ------------------------------------------------------------------
# Execution: append
# ------------------------------------------------------------------
def execute_append(
self,
correction_id: str,
) -> CorrectionResult:
"""Execute an append correction.
Spawns a new child plan reference and preserves the original
decision node.
Args:
correction_id: Correction request ID.
Returns:
``CorrectionResult`` with the spawned child plan ID.
Raises:
ResourceNotFoundError: If correction does not exist.
ValidationError: If correction is not in PENDING or ANALYZING status.
"""
request = self._get_request_or_raise(correction_id)
self._assert_executable(request)
request.status = CorrectionStatus.EXECUTING
attempt = CorrectionAttempt(correction_id=correction_id)
self._attempts[correction_id].append(attempt)
try:
child_plan_id = str(ULID())
new_decision_id = str(ULID())
result = CorrectionResult(
correction_id=correction_id,
status=CorrectionStatus.APPLIED,
new_decisions=[new_decision_id],
spawned_child_plan_id=child_plan_id,
)
request.status = CorrectionStatus.APPLIED
attempt.success = True
attempt.details = {
"spawned_child_plan_id": child_plan_id,
"new_decision_id": new_decision_id,
}
except Exception as exc:
result = CorrectionResult(
correction_id=correction_id,
status=CorrectionStatus.FAILED,
error_message=str(exc),
)
request.status = CorrectionStatus.FAILED
attempt.success = False
attempt.details = {"error": str(exc)}
finally:
attempt.completed_at = datetime.now(UTC)
self._results[correction_id] = result
logger.info(
"correction.append_executed",
correction_id=correction_id,
status=result.status,
)
return result
# ------------------------------------------------------------------
# Dispatch
# ------------------------------------------------------------------
def execute_correction(
self,
correction_id: str,
decision_tree: dict[str, list[str]] | None = None,
) -> CorrectionResult:
"""Execute a correction, dispatching to revert or append.
Args:
correction_id: Correction request ID.
decision_tree: Optional adjacency list (used for revert).
Returns:
``CorrectionResult`` from the chosen strategy.
"""
request = self._get_request_or_raise(correction_id)
if request.mode == CorrectionMode.REVERT:
return self.execute_revert(correction_id, decision_tree)
return self.execute_append(correction_id)
# ------------------------------------------------------------------
# Query helpers
# ------------------------------------------------------------------
def get_correction(self, correction_id: str) -> CorrectionRequest:
"""Retrieve a correction request by ID.
Raises:
ResourceNotFoundError: If not found.
"""
return self._get_or_raise(correction_id)
return self._get_request_or_raise(correction_id)
def list_corrections(self, plan_id: str | None = None) -> list[CorrectionRequest]:
"""List corrections, optionally filtered by plan.
Args:
plan_id: If provided, only return corrections for this plan.
Returns:
List of matching corrections.
"""
"""List corrections, optionally filtered by plan_id."""
corrections = list(self._corrections.values())
if plan_id is not None:
corrections = [c for c in corrections if c.plan_id == plan_id]
return corrections
def list_attempts(self, correction_id: str) -> list[CorrectionAttempt]:
"""List attempts for a given correction.
"""List execution attempts for a correction.
Args:
correction_id: The correction whose attempts to list.
Returns:
List of :class:`CorrectionAttempt` objects.
Raises:
ResourceNotFoundError: If correction does not exist.
"""
self._get_request_or_raise(correction_id)
return list(self._attempts.get(correction_id, []))
# ── Cancel ──────────────────────────────────────────────────
def cancel_correction(self, correction_id: str) -> CorrectionRequest:
"""Cancel a pending correction.
Args:
correction_id: The correction to cancel.
Returns:
The updated :class:`CorrectionRequest`.
Raises:
ResourceNotFoundError: If not found.
ValidationError: If the correction is already terminal.
ResourceNotFoundError: If correction does not exist.
ValidationError: If correction is not in a cancellable status.
"""
correction = self._get_or_raise(correction_id)
if correction.status in {
CorrectionStatus.APPLIED,
CorrectionStatus.CANCELLED,
}:
request = self._get_request_or_raise(correction_id)
cancellable = {CorrectionStatus.PENDING, CorrectionStatus.ANALYZING}
if request.status not in cancellable:
raise ValidationError(
f"Cannot cancel correction in {correction.status.value} state"
f"Cannot cancel correction in '{request.status}' status. "
f"Cancellation is only allowed in: {sorted(cancellable)}"
)
request.status = CorrectionStatus.CANCELLED
logger.info(
"correction.cancelled",
correction_id=correction_id,
)
return request
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_request_or_raise(self, correction_id: str) -> CorrectionRequest:
"""Look up a correction or raise ``ResourceNotFoundError``."""
request = self._corrections.get(correction_id)
if request is None:
raise ResourceNotFoundError(
resource_type="correction",
resource_id=correction_id,
)
return request
def _assert_executable(self, request: CorrectionRequest) -> None:
"""Ensure the correction is in an executable status."""
executable = {CorrectionStatus.PENDING, CorrectionStatus.ANALYZING}
if request.status not in executable:
raise ValidationError(
f"Cannot execute correction in '{request.status}' status. "
f"Execution requires status in: {sorted(executable)}"
)
correction.status = CorrectionStatus.CANCELLED
return correction
@staticmethod
def _compute_affected_subtree(
target_id: str, tree: dict[str, list[str]]
) -> list[str]:
"""BFS walk from *target_id* through the decision tree.
# ── Helpers ──────────────────────────────────────────────────
Returns all reachable node IDs (inclusive of the target itself),
preserving BFS visit order.
"""
affected: list[str] = []
queue: deque[str] = deque([target_id])
while queue:
node = queue.popleft()
affected.append(node)
queue.extend(tree.get(node, []))
return affected
def _get_or_raise(self, correction_id: str) -> CorrectionRequest:
"""Look up a correction by ID or raise."""
correction = self._corrections.get(correction_id)
if correction is None:
raise ResourceNotFoundError(f"Correction not found: {correction_id}")
return correction
@staticmethod
def _classify_risk(affected_count: int) -> str:
"""Classify risk level based on affected subtree size."""
if affected_count <= _RISK_LOW_MAX:
return "low"
if affected_count <= _RISK_MEDIUM_MAX:
return "medium"
return "high"
__all__ = [
"CorrectionService",
]
@@ -41,6 +41,7 @@ from cleveragents.domain.models.core.context_policy import (
)
from cleveragents.domain.models.core.correction import (
CorrectionAttempt,
CorrectionDryRunReport,
CorrectionImpact,
CorrectionMode,
CorrectionRequest,
@@ -188,6 +189,7 @@ __all__ = [
"ContextUpdateResult",
"ContextView",
"CorrectionAttempt",
"CorrectionDryRunReport",
"CorrectionImpact",
"CorrectionMode",
"CorrectionRequest",
+173 -94
View File
@@ -1,35 +1,28 @@
"""Correction domain models for decision tree editing.
Corrections allow users to edit the decision tree and recompute affected
subtrees. Two correction modes are supported:
* **revert** -- undo a specific decision and all descendants
* **append** -- add new guidance at a decision point without removing existing work
Each correction targets a specific decision in the plan's decision tree.
Supports revert and append correction modes with impact analysis,
dry-run reporting, and execution flow tracking.
"""
from __future__ import annotations
from datetime import datetime
from datetime import UTC, datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ulid import ULID
# ── Enums ──────────────────────────────────────────────────────────
class CorrectionMode(StrEnum):
"""Mode of correction to apply to the decision tree."""
"""Available correction strategies."""
REVERT = "revert"
APPEND = "append"
class CorrectionStatus(StrEnum):
"""Status of a correction through its lifecycle."""
"""Lifecycle states for a correction request."""
PENDING = "pending"
ANALYZING = "analyzing"
@@ -39,43 +32,46 @@ class CorrectionStatus(StrEnum):
CANCELLED = "cancelled"
# ── Domain Models ──────────────────────────────────────────────────
def _generate_ulid() -> str:
"""Generate a new ULID string."""
return str(ULID())
class CorrectionRequest(BaseModel):
"""A request to correct a decision in a plan's decision tree.
"""A request to correct a decision within a plan's decision tree.
Attributes:
correction_id: Unique ULID identifier for the correction.
plan_id: The plan whose decision tree is being corrected.
target_decision_id: The decision ID to target for correction.
mode: Whether to revert or append.
guidance: User-supplied guidance text for the correction.
dry_run: If ``True``, only analyze impact without executing.
created_at: When the request was created.
status: Current status of the correction.
Each request targets a specific decision node and applies either a
*revert* (invalidate subtree) or *append* (add new child plan) strategy.
"""
model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True)
model_config = ConfigDict(frozen=False, populate_by_name=True)
correction_id: str = Field(default_factory=_generate_ulid, description="ULID")
plan_id: str = Field(..., description="Plan being corrected")
target_decision_id: str = Field(
..., description="Decision ID to target for correction"
correction_id: str = Field(
default_factory=lambda: str(ULID()),
description="Unique correction identifier (ULID).",
)
plan_id: str = Field(
...,
description="Plan that owns the targeted decision tree.",
)
target_decision_id: str = Field(
...,
description="Decision node to apply correction at.",
)
mode: CorrectionMode = Field(
...,
description="Correction strategy: revert or append.",
)
guidance: str = Field(
default="",
description="Human-supplied guidance for the correction.",
)
dry_run: bool = Field(
default=False,
description="When True, only compute impact without executing.",
)
mode: CorrectionMode = Field(..., description="Correction mode")
guidance: str = Field(..., description="User-supplied guidance text")
dry_run: bool = Field(False, description="Analyze only, do not execute")
created_at: datetime = Field(
default_factory=datetime.now, description="When request was created"
default_factory=lambda: datetime.now(UTC),
description="Timestamp when the request was created.",
)
status: CorrectionStatus = Field(
default=CorrectionStatus.PENDING, description="Current status"
default=CorrectionStatus.PENDING,
description="Current lifecycle status.",
)
@field_validator("plan_id")
@@ -83,111 +79,194 @@ class CorrectionRequest(BaseModel):
def _plan_id_not_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("plan_id must not be empty")
return v.strip()
return v
@field_validator("target_decision_id")
@classmethod
def _target_decision_id_not_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("target_decision_id must not be empty")
return v.strip()
return v
@field_validator("guidance")
@classmethod
def _guidance_not_empty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("guidance must not be empty")
def _guidance_stripped(cls, v: str) -> str:
return v.strip()
class CorrectionImpact(BaseModel):
"""Impact analysis for a proposed correction.
"""Impact analysis result for a proposed correction.
Returned by ``CorrectionService.analyze_impact`` to describe what
would change if the correction were executed.
Attributes:
affected_decisions: Decision IDs that would be recomputed.
affected_files: File paths that would change.
estimated_cost: Optional estimated cost in credits.
risk_level: ``low``, ``medium``, or ``high``.
Computed via BFS traversal of the decision tree rooted at the
targeted decision node.
"""
model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True)
model_config = ConfigDict(frozen=True)
affected_decisions: list[str] = Field(
default_factory=list,
description="Decision IDs that would be recomputed",
description="Decision IDs in the affected subtree (BFS order).",
)
affected_files: list[str] = Field(
default_factory=list,
description="File paths that would change",
description="Files touched by affected decisions.",
)
affected_child_plans: list[str] = Field(
default_factory=list,
description="Child plan IDs spawned by affected decisions.",
)
estimated_cost: float | None = Field(
default=None,
description="Estimated recompute cost (arbitrary units).",
)
risk_level: str = Field(
default="low",
description="Risk classification: low, medium, or high.",
)
rollback_tier: str = Field(
default="full",
description="Rollback granularity: full, phase, or append_only.",
)
artifacts_to_archive: list[str] = Field(
default_factory=list,
description="Artifact paths that should be archived on revert.",
)
estimated_cost: float | None = Field(None, description="Estimated cost in credits")
risk_level: str = Field("low", description="Risk level: low, medium, or high")
@field_validator("risk_level")
@classmethod
def _valid_risk_level(cls, v: str) -> str:
allowed = {"low", "medium", "high"}
if v not in allowed:
raise ValueError(f"risk_level must be one of {sorted(allowed)}, got {v!r}")
raise ValueError(f"risk_level must be one of {allowed}, got '{v}'")
return v
@field_validator("rollback_tier")
@classmethod
def _valid_rollback_tier(cls, v: str) -> str:
allowed = {"full", "phase", "append_only"}
if v not in allowed:
raise ValueError(f"rollback_tier must be one of {allowed}, got '{v}'")
return v
class CorrectionResult(BaseModel):
"""Result of executing a correction.
"""Outcome of executing a correction."""
Attributes:
correction_id: The correction that was executed.
status: Final status after execution.
new_decisions: New decision IDs created by the correction.
reverted_decisions: Decision IDs that were reverted.
error_message: Error detail if the correction failed.
completed_at: When execution finished.
"""
model_config = ConfigDict(frozen=True)
model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True)
correction_id: str = Field(..., description="Correction that was executed")
status: CorrectionStatus = Field(..., description="Final status")
correction_id: str = Field(
...,
description="Correction request this result belongs to.",
)
status: CorrectionStatus = Field(
...,
description="Terminal status after execution.",
)
new_decisions: list[str] = Field(
default_factory=list, description="New decision IDs"
default_factory=list,
description="Decision IDs created by an append correction.",
)
reverted_decisions: list[str] = Field(
default_factory=list, description="Reverted decision IDs"
default_factory=list,
description="Decision IDs invalidated by a revert correction.",
)
spawned_child_plan_id: str | None = Field(
default=None,
description="Child plan spawned for append corrections.",
)
archived_artifacts: list[str] = Field(
default_factory=list,
description="Artifacts archived during revert.",
)
error_message: str | None = Field(
default=None, description="Error detail if failed"
default=None,
description="Error details when status is FAILED.",
)
completed_at: datetime = Field(
default_factory=datetime.now, description="When execution finished"
default_factory=lambda: datetime.now(UTC),
description="Timestamp when execution finished.",
)
class CorrectionAttempt(BaseModel):
"""A single execution attempt for a correction.
"""Record of a single correction execution attempt.
Tracks individual attempts so that retries are recorded separately.
Attributes:
attempt_id: Unique ULID for the attempt.
correction_id: The parent correction.
started_at: When the attempt started.
completed_at: When the attempt finished (if done).
success: Whether the attempt succeeded.
details: Arbitrary metadata about the attempt.
Multiple attempts may occur if a correction is retried after failure.
"""
model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True)
model_config = ConfigDict(frozen=False)
attempt_id: str = Field(default_factory=_generate_ulid, description="ULID")
correction_id: str = Field(..., description="Parent correction ID")
attempt_id: str = Field(
default_factory=lambda: str(ULID()),
description="Unique attempt identifier (ULID).",
)
correction_id: str = Field(
...,
description="Parent correction request ID.",
)
started_at: datetime = Field(
default_factory=datetime.now, description="Attempt start time"
default_factory=lambda: datetime.now(UTC),
description="When the attempt started.",
)
completed_at: datetime | None = Field(
default=None,
description="When the attempt finished (None if still running).",
)
success: bool = Field(
default=False,
description="Whether the attempt succeeded.",
)
completed_at: datetime | None = Field(None, description="Attempt completion time")
success: bool = Field(False, description="Whether attempt succeeded")
details: dict[str, Any] = Field(
default_factory=dict, description="Metadata about the attempt"
default_factory=dict,
description="Arbitrary execution details.",
)
class CorrectionDryRunReport(BaseModel):
"""Detailed dry-run report showing what a correction *would* do.
Generated by ``CorrectionService.generate_dry_run_report`` without
actually modifying any state.
"""
model_config = ConfigDict(frozen=True)
correction_id: str = Field(
...,
description="Correction request this report describes.",
)
mode: CorrectionMode = Field(
...,
description="Correction strategy that would be applied.",
)
impact: CorrectionImpact = Field(
...,
description="Full impact analysis.",
)
decisions_to_invalidate: list[str] = Field(
default_factory=list,
description="Decisions that would be marked invalid.",
)
child_plans_to_rollback: list[str] = Field(
default_factory=list,
description="Child plans that would be rolled back.",
)
estimated_recompute_time_seconds: float | None = Field(
default=None,
description="Estimated wall-clock seconds to recompute.",
)
warnings: list[str] = Field(
default_factory=list,
description="Human-readable warnings about the correction.",
)
__all__ = [
"CorrectionAttempt",
"CorrectionDryRunReport",
"CorrectionImpact",
"CorrectionMode",
"CorrectionRequest",
"CorrectionResult",
"CorrectionStatus",
]