feat(service): add decision recording and snapshot store #433

Merged
hamza.khyari merged 9 commits from feature/m4-decision-service into master 2026-03-03 12:58:17 +00:00
20 changed files with 3074 additions and 367 deletions
+2
View File
@@ -191,6 +191,8 @@
- Added `ReadOnlyViolationError` to `ChangeSetCapture` to prevent write artifacts on
read-only plans.
- Added CLI fail-fast guards on `plan execute` and `plan apply` for read-only plans.
- Added `DecisionService` with record/list/tree helpers and `SnapshotStore` for hash-based
deduplication of context snapshots during plan execution.
- Expanded CONTRIBUTING.md with detailed guidance on the issue creation process, label system,
ticket lifecycle, pull request requirements, and review/merge process.
- Added commit scope, quality, and message format guidelines to CONTRIBUTING.md.
+35 -57
View File
@@ -11,10 +11,11 @@ from __future__ import annotations
import sys
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import MagicMock
from unittest.mock import create_autospec
try:
from cleveragents.application.services.decision_service import DecisionService
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.decision import Decision, DecisionType
from cleveragents.domain.models.core.plan import (
@@ -25,11 +26,11 @@ try:
PlanTimestamps,
ProcessingState,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.application.services.decision_service import DecisionService
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.decision import Decision, DecisionType
from cleveragents.domain.models.core.plan import (
@@ -40,31 +41,16 @@ except ModuleNotFoundError:
PlanTimestamps,
ProcessingState,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
_PLAN_ID = "01HV00000000000000DIBENCH1"
def _make_uow() -> UnitOfWork:
"""Create a UoW backed by an in-memory SQLite database."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
uow = UnitOfWork.__new__(UnitOfWork)
uow.database_url = "sqlite:///:memory:"
uow._engine = engine
uow._session_factory = sessionmaker(
bind=engine,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
uow._database_initialized = True
uow._prompt_for_migration = None
uow = UnitOfWork("sqlite:///:memory:")
uow.init_database()
return uow
@@ -106,21 +92,6 @@ def _seed_prerequisites(uow: UnitOfWork) -> None:
plan_repo.create(plan)
def _make_decision(
seq: int = 0,
parent_id: str | None = None,
dtype: DecisionType = DecisionType.PROMPT_DEFINITION,
) -> Decision:
return Decision(
plan_id=_PLAN_ID,
parent_decision_id=parent_id,
sequence_number=seq,
decision_type=dtype,
question="Benchmark question?",
chosen_option="Benchmark option",
)
class TimeDecisionServiceResolution:
"""Benchmark DecisionService resolution from the DI container."""
@@ -155,16 +126,19 @@ class TimeDecisionRecord:
def setup(self) -> None:
self.uow = _make_uow()
_seed_prerequisites(self.uow)
self.svc = DecisionService(settings=MagicMock(), unit_of_work=self.uow)
mock_settings = create_autospec(Settings, instance=True)
mock_settings.database_url = "sqlite:///:memory:"
self.svc = DecisionService(settings=mock_settings, unit_of_work=self.uow)
self._seq = 100
def time_decision_record(self) -> None:
self._seq += 1
d = _make_decision(
seq=self._seq,
dtype=DecisionType.STRATEGY_CHOICE,
self.svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Benchmark question?",
chosen_option="Benchmark option",
)
self.svc.record_decision(d)
class TimeDecisionTreeRetrieval:
@@ -175,30 +149,34 @@ class TimeDecisionTreeRetrieval:
def setup(self) -> None:
self.uow = _make_uow()
_seed_prerequisites(self.uow)
self.svc = DecisionService(settings=MagicMock(), unit_of_work=self.uow)
mock_settings = create_autospec(Settings, instance=True)
mock_settings.database_url = "sqlite:///:memory:"
self.svc = DecisionService(settings=mock_settings, unit_of_work=self.uow)
# Build a small tree
root = _make_decision(seq=0)
self.svc.record_decision(root)
self.root_id = root.decision_id
root = self.svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.PROMPT_DEFINITION,
question="Benchmark question?",
chosen_option="Benchmark option",
)
seq = 1
for _ in range(3):
child = _make_decision(
seq=seq,
parent_id=root.decision_id,
dtype=DecisionType.STRATEGY_CHOICE,
child = self.svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Benchmark question?",
chosen_option="Benchmark option",
parent_decision_id=root.decision_id,
)
self.svc.record_decision(child)
for _ in range(2):
seq += 1
gc = _make_decision(
seq=seq,
parent_id=child.decision_id,
dtype=DecisionType.IMPLEMENTATION_CHOICE,
self.svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
question="Benchmark question?",
chosen_option="Benchmark option",
parent_decision_id=child.decision_id,
)
self.svc.record_decision(gc)
seq += 1
def time_decision_tree_retrieval(self) -> None:
self.svc.get_decision_tree(self.root_id)
self.svc.get_tree(_PLAN_ID)
+169
View File
@@ -0,0 +1,169 @@
"""ASV benchmarks for DecisionService recording throughput.
Measures record_decision, list_decisions, get_tree, and snapshot operations.
"""
from __future__ import annotations
import sys
from pathlib import Path
try:
from cleveragents.application.services.decision_service import (
DecisionService,
SnapshotStore,
)
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
DecisionType,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.application.services.decision_service import (
DecisionService,
SnapshotStore,
)
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
DecisionType,
)
_PLAN_ID = "01HV00000000000000BENCH002"
class TimeRecordDecision:
"""Benchmark decision recording throughput."""
timeout = 60
def setup(self):
self.svc = DecisionService()
self._plan_counter = 0
def time_record_single(self):
self._plan_counter += 1
plan_id = f"01HV00000000000000BN{self._plan_counter:06d}"
self.svc.record_decision(
plan_id=plan_id,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Benchmark question",
chosen_option="Benchmark choice",
)
class TimeRecordBatch:
"""Benchmark recording 50 decisions for one plan."""
timeout = 60
def time_record_50(self):
svc = DecisionService()
root = svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.PROMPT_DEFINITION,
question="Root",
chosen_option="Root choice",
)
for _ in range(49):
svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Child",
chosen_option="Child choice",
parent_decision_id=root.decision_id,
)
class TimeListDecisions:
"""Benchmark list_decisions with 50 pre-seeded decisions."""
timeout = 60
def setup(self):
self.svc = DecisionService()
root = self.svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.PROMPT_DEFINITION,
question="Root",
chosen_option="Root choice",
)
for _ in range(49):
self.svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Child",
chosen_option="Child choice",
parent_decision_id=root.decision_id,
)
def time_list_all(self):
self.svc.list_decisions(_PLAN_ID)
def time_list_by_type(self):
self.svc.list_by_type(_PLAN_ID, DecisionType.STRATEGY_CHOICE)
class TimeGetTree:
"""Benchmark get_tree with a 3-level tree (1 + 3 + 6 = 10 nodes)."""
timeout = 60
def setup(self):
self.svc = DecisionService()
root = self.svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.PROMPT_DEFINITION,
question="Root",
chosen_option="Root choice",
)
for _ in range(3):
child = self.svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Child",
chosen_option="Child choice",
parent_decision_id=root.decision_id,
)
for _ in range(2):
self.svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
question="Grandchild",
chosen_option="GC choice",
parent_decision_id=child.decision_id,
)
def time_get_tree(self):
self.svc.get_tree(_PLAN_ID)
class TimeSnapshotStore:
"""Benchmark SnapshotStore operations."""
timeout = 60
def setup(self):
self.store = SnapshotStore()
self._counter = 0
# Pre-seed 100 snapshots
for i in range(100):
snap = ContextSnapshot(
hot_context_hash=f"sha256:bench{i:04d}",
hot_context_ref=f"ref-{i}",
)
self.store.store(f"DEC_{i:06d}", snap)
def time_store_snapshot(self):
self._counter += 1
snap = ContextSnapshot(
hot_context_hash=f"sha256:new{self._counter:06d}",
hot_context_ref=f"ref-new-{self._counter}",
)
self.store.store(f"NEW_{self._counter:06d}", snap)
def time_get_by_hash(self):
self.store.get_by_hash("sha256:bench0050")
def time_get_snapshot(self):
self.store.get("DEC_000050")
+148
View File
@@ -0,0 +1,148 @@
# Decision Service Reference
## Overview
`DecisionService` provides the application-layer interface for recording
decisions during plan execution, retrieving decision histories, and
managing context snapshots. It lives in
`src/cleveragents/application/services/decision_service.py`.
## Dual-Mode Persistence
| Mode | UnitOfWork | Storage |
|------------|------------|------------------------------------------------|
| In-memory | `None` | Internal dicts (`_decisions`, `_plan_decisions`) |
| Persisted | provided | DB via `DecisionRepository` + in-memory cache |
When a `UnitOfWork` is wired, mutations are written to the database
first, then the in-memory cache is updated (write-through).
## Constructor
```python
DecisionService(
settings: Settings | None = None,
unit_of_work: UnitOfWork | None = None,
)
```
- **settings** Application settings (optional for in-memory mode).
- **unit_of_work** When provided, decisions are persisted via
`DecisionRepository`.
## Recording
### `record_decision()`
```python
svc.record_decision(
plan_id="01HV...",
decision_type=DecisionType.STRATEGY_CHOICE, # or a string
question="Which approach?",
chosen_option="Build a REST API",
parent_decision_id=None, # optional
alternatives_considered=None, # list[str]
confidence_score=None, # 0.0-1.0
rationale="", # human-readable
actor_reasoning=None, # raw LLM trace
context_snapshot=None, # auto-captured if omitted
artifacts_produced=None, # list[ArtifactRef]
is_correction=False,
corrects_decision_id=None,
correction_reason=None,
)
```
- Auto-assigns a monotonically increasing **sequence number** per plan.
- Auto-generates a **ULID** for `decision_id`.
- Auto-captures a **context snapshot** (SHA-256 hash) when no explicit
snapshot is provided.
- Stores the snapshot in the built-in `SnapshotStore`.
**Raises:**
- `ValidationError` required fields missing or empty.
- `DuplicateDecisionError` decision ID already exists.
## Retrieval
| Method | Description |
|---------------------------------------|--------------------------------------------|
| `get_decision(decision_id)` | Single decision by ULID |
| `list_decisions(plan_id)` | All decisions for a plan, ordered by seq |
| `list_by_type(plan_id, decision_type)`| Filtered by type, ordered by seq |
All raise `DecisionNotFoundError` when the target decision does not
exist (where applicable).
## Tree Operations
| Method | Description |
|-------------------------------|------------------------------------------|
| `get_tree(plan_id)` | BFS from root(s), level by level |
| `get_path_to_root(decision_id)` | Walk from a decision up to the root |
## Superseded Decisions
| Method | Description |
|-----------------------------------------------|---------------------------------------|
| `mark_superseded(decision_id, new_decision_id)` | Mark a decision as superseded |
| `get_superseded(plan_id)` | List all superseded decisions |
## Delete
```python
svc.delete_decision(decision_id) # -> True
```
Removes from both persistence and the snapshot store. Raises
`DecisionNotFoundError` if not found.
## Snapshot Store
`SnapshotStore` manages `ContextSnapshot` objects keyed by decision ID
with hash-based deduplication.
| Method | Description |
|---------------------------------|----------------------------------------------|
| `get_snapshot(decision_id)` | Retrieve snapshot for a decision |
| `get_snapshots_for_plan(plan_id)` | All snapshots for a plan's decisions |
| `snapshots.get_by_hash(hash)` | Decision IDs sharing the same context hash |
| `snapshots.store(id, snap)` | Store a snapshot (internal) |
| `snapshots.remove(id)` | Remove a snapshot |
## Statistics
| Method | Description |
|-------------------------------|----------------------------------|
| `count_decisions(plan_id)` | Number of decisions for a plan |
| `get_next_sequence(plan_id)` | Next available sequence number |
## Custom Exceptions
| Exception | Base | Description |
|--------------------------|--------------------------|-------------------------------------|
| `DuplicateDecisionError` | `BusinessRuleViolation` | Decision ID already exists |
| `DecisionNotFoundError` | `ResourceNotFoundError` | Decision not found |
| `SequenceConflictError` | `BusinessRuleViolation` | Sequence number already used |
## Running Tests
```bash
# Behave BDD tests
nox -s unit_tests -- features/decision_recording.feature
# Robot Framework integration smoke tests
nox -s integration_tests
# ASV benchmarks
nox -s benchmark
```
## Related
- Domain model: `src/cleveragents/domain/models/core/decision.py`
- Persistence: `src/cleveragents/infrastructure/database/repositories.py`
(`DecisionRepository`)
- Database schema: `docs/reference/database_schema.md`
- ADR: `docs/adr/ADR-033-decision-recording-protocol.md`
+5 -7
View File
@@ -51,8 +51,8 @@ decision_service = providers.Factory(
|-------------------------|--------------------------------------------|
| `record_decision()` | Persist a new decision |
| `get_decision()` | Retrieve a decision by ID |
| `get_decisions_for_plan()` | Get all decisions for a plan |
| `get_decision_tree()` | BFS traversal of the decision tree |
| `list_decisions()` | Get all decisions for a plan |
| `get_tree()` | BFS traversal of the decision tree |
| `get_path_to_root()` | Walk from a decision up to the root |
| `mark_superseded()` | Mark a decision as superseded |
| `list_by_type()` | List decisions by type for a plan |
@@ -92,17 +92,15 @@ decision_svc = container.decision_service()
lifecycle_svc = container.plan_lifecycle_service()
# Record a decision manually
from cleveragents.domain.models.core.decision import Decision, DecisionType
from cleveragents.domain.models.core.decision import DecisionType
decision = Decision(
decision = decision_svc.record_decision(
plan_id="01HV...",
sequence_number=0,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Which approach?",
chosen_option="Approach A",
)
decision_svc.record_decision(decision)
# Retrieve decisions
decisions = decision_svc.get_decisions_for_plan("01HV...")
decisions = decision_svc.list_decisions("01HV...")
```
+403
View File
@@ -0,0 +1,403 @@
Feature: Decision recording and snapshot store
As a developer
I want a DecisionService that records decisions with auto-sequencing and snapshots
So that plan decision trees are tracked with full context for replay
Background:
Given a decision service
# --- Recording ---
Scenario: Record a root decision with auto-sequencing
When I record a prompt_definition decision for plan "P1" with question "What is the plan prompt?"
Then the dsvc decision should be recorded successfully
And the dsvc decision sequence number should be 0
And the dsvc decision type should be "prompt_definition"
And the dsvc decision should be a root decision
And the dsvc decision should have a valid ULID as decision_id
And the dsvc decision context snapshot hash should not be empty
Scenario: Record multiple decisions with monotonic sequencing
When I record a prompt_definition decision for plan "P1" with question "Plan prompt"
And I record a strategy_choice decision for plan "P1" with question "Which approach?"
And I record an implementation_choice decision for plan "P1" with question "How to implement?"
Then the dsvc plan "P1" should have 3 decisions
And the dsvc decisions should have sequence numbers 0 1 2
Scenario: Record a decision with explicit context snapshot
Given a context snapshot with hash "sha256:abc123" and ref "s3://bucket/ctx"
When I record a strategy_choice decision for plan "P1" with the explicit snapshot
Then the dsvc decision context snapshot hash should be "sha256:abc123"
And the dsvc decision context snapshot ref should be "s3://bucket/ctx"
Scenario: Record a decision with confidence score
When I record a strategy_choice decision for plan "P1" with confidence 0.85
Then the dsvc decision confidence score should be 0.85
Scenario: Record a correction decision
When I record a prompt_definition decision for plan "P1" with question "Original"
And I record a correction decision for plan "P1" correcting the first decision
Then the dsvc correction decision is_correction should be true
And the dsvc correction decision corrects_decision_id should match the first decision
Scenario: Record a decision with alternatives
When I record a strategy_choice decision for plan "P1" with alternatives "Option A" "Option B" "Option C"
Then the dsvc decision should have 3 alternatives considered
Scenario: Record a decision with artifacts
When I record an implementation_choice decision for plan "P1" with artifacts
Then the dsvc decision should have 2 artifacts produced
Scenario: Recording with empty plan_id raises ValidationError
When I try to record a decision with empty plan_id
Then a dsvc validation error should be raised
And the dsvc error should mention "plan_id"
Scenario: Recording with empty question raises ValidationError
When I try to record a decision with empty question
Then a dsvc validation error should be raised
And the dsvc error should mention "question"
Scenario: Recording with empty chosen_option raises ValidationError
When I try to record a decision with empty chosen_option
Then a dsvc validation error should be raised
And the dsvc error should mention "chosen_option"
Scenario: Recording with invalid decision_type raises ValueError
When I try to record a decision with invalid type "nonexistent_type"
Then a dsvc value error should be raised
And the dsvc error should mention "nonexistent_type"
Scenario: Recording with confidence_score at boundaries
When I record a strategy_choice decision for plan "P1" with confidence 0.0
Then the dsvc decision confidence score should be 0.0
When I record a strategy_choice decision for plan "P1" with confidence 1.0
Then the dsvc decision confidence score should be 1.0
Scenario: Recording with negative confidence_score raises ValidationError
When I try to record a decision with confidence -0.1
Then a dsvc validation error should be raised
Scenario: Recording with confidence_score above 1.0 raises ValidationError
When I try to record a decision with confidence 1.1
Then a dsvc validation error should be raised
Scenario: Auto-capture generates a snapshot hash
When I record a strategy_choice decision for plan "P1" with question "Test auto-capture"
Then the dsvc decision context snapshot hash should start with "sha256:"
# --- Retrieval ---
Scenario: Get a decision by ID
When I record a prompt_definition decision for plan "P1" with question "Test get"
And I get the decision by its ID
Then the dsvc retrieved decision should match the recorded decision
Scenario: Get a non-existent decision raises DecisionNotFoundError
When I try to get decision "01NONEXISTENT00000000000000"
Then a dsvc decision not found error should be raised
Scenario: List decisions for a plan returns all decisions ordered
When I record a prompt_definition decision for plan "P1" with question "First"
And I record a strategy_choice decision for plan "P1" with question "Second"
And I record an implementation_choice decision for plan "P1" with question "Third"
And I list decisions for plan "P1"
Then the dsvc decision list should have 3 entries
And the dsvc decision list should be ordered by sequence number
Scenario: List decisions for empty plan returns empty list
When I list decisions for plan "P_EMPTY"
Then the dsvc decision list should have 0 entries
Scenario: List decisions by type filters correctly
When I record a prompt_definition decision for plan "P1" with question "Root"
And I record a strategy_choice decision for plan "P1" with question "Strategy 1"
And I record a strategy_choice decision for plan "P1" with question "Strategy 2"
And I record an implementation_choice decision for plan "P1" with question "Impl"
And I filter decisions for plan "P1" by type "strategy_choice"
Then the dsvc decision list should have 2 entries
# --- Tree operations ---
Scenario: Get tree returns BFS order from root
When I record a prompt_definition decision for plan "P1" with question "Root"
And I record a strategy_choice decision for plan "P1" with parent as child "Child 1"
And I record a strategy_choice decision for plan "P1" with parent as child "Child 2"
And I record an implementation_choice decision for plan "P1" with second parent as child "Grandchild"
And I get the tree for plan "P1"
Then the dsvc tree should have 4 decisions
And the dsvc first tree decision should be a root decision
And the dsvc tree should be in BFS level order
Scenario: Get tree for empty plan returns empty list
When I get the tree for plan "P_EMPTY"
Then the dsvc tree should have 0 decisions
Scenario: Get path to root navigates upward
When I record a prompt_definition decision for plan "P1" with question "Root"
And I record a strategy_choice decision for plan "P1" with parent as child "Level 1"
And I record an implementation_choice decision for plan "P1" with second parent as child "Level 2"
And I get the path to root from the last decision
Then the dsvc path should have 3 decisions
And the dsvc first path decision should be the last recorded decision
And the dsvc last path decision should be the root
Scenario: Get path to root for non-existent decision raises error
When I try to get path to root for decision "01NONEXISTENT00000000000000"
Then a dsvc decision not found error should be raised
# --- Superseded decisions ---
Scenario: Mark a decision as superseded
When I record a prompt_definition decision for plan "P1" with question "Original"
And I record a strategy_choice decision for plan "P1" with question "Replacement"
And I mark the first decision as superseded by the second
Then the dsvc first decision should be superseded
And the dsvc first decision superseded_by should match the second decision
Scenario: Get superseded decisions for a plan
When I record a prompt_definition decision for plan "P1" with question "Original"
And I record a strategy_choice decision for plan "P1" with question "Replacement"
And I mark the first decision as superseded by the second
And I get superseded decisions for plan "P1"
Then the dsvc superseded list should have 1 entry
Scenario: Mark non-existent decision raises DecisionNotFoundError
When I try to mark decision "01NONEXISTENT00000000000000" as superseded
Then a dsvc decision not found error should be raised
# --- Delete ---
Scenario: Delete a decision
When I record a prompt_definition decision for plan "P1" with question "To delete"
And I delete the recorded decision
Then the dsvc decision should be deleted
And the dsvc plan "P1" should have 0 decisions
Scenario: Delete non-existent decision raises DecisionNotFoundError
When I try to delete decision "01NONEXISTENT00000000000000"
Then a dsvc decision not found error should be raised
# --- Snapshot store ---
Scenario: Snapshot is stored when decision is recorded
When I record a strategy_choice decision for plan "P1" with question "Snapshot test"
And I get the snapshot for the recorded decision
Then the dsvc snapshot should not be None
Scenario: Get snapshots for plan returns all snapshots
When I record a prompt_definition decision for plan "P1" with question "First"
And I record a strategy_choice decision for plan "P1" with question "Second"
And I get snapshots for plan "P1"
Then the dsvc snapshots dict should have 2 entries
Scenario: Snapshot hash deduplication index works
Given a context snapshot with hash "sha256:samehash" and ref "ref1"
When I record a strategy_choice decision for plan "P1" with the explicit snapshot
And I record a strategy_choice decision for plan "P2" with the same explicit snapshot
And I query the snapshot store by hash "sha256:samehash"
Then the dsvc hash query should return 2 decision IDs
Scenario: Snapshot is removed when decision is deleted
When I record a prompt_definition decision for plan "P1" with question "Ephemeral"
And I delete the recorded decision
And I get the snapshot for the deleted decision
Then the dsvc snapshot should be None
# --- Statistics ---
Scenario: Count decisions for a plan
When I record a prompt_definition decision for plan "P1" with question "One"
And I record a strategy_choice decision for plan "P1" with question "Two"
Then the dsvc decision count for plan "P1" should be 2
Scenario: Get next sequence returns the expected value
Then the dsvc next sequence for plan "P_NEW" should be 0
When I record a prompt_definition decision for plan "P_NEW" with question "First"
Then the dsvc next sequence for plan "P_NEW" should be 1
# --- DuplicateDecisionError ---
Scenario: DuplicateDecisionError contains decision_id
Given a DuplicateDecisionError for decision "DUP123"
Then the dsvc duplicate error decision_id should be "DUP123"
And the dsvc duplicate error message should contain "DUP123"
# --- SequenceConflictError ---
Scenario: SequenceConflictError contains plan_id and sequence
Given a SequenceConflictError for plan "P1" sequence 5
Then the dsvc sequence error plan_id should be "P1"
And the dsvc sequence error sequence_number should be 5
And the dsvc sequence error message should contain "P1"
# --- SnapshotStore standalone ---
Scenario: SnapshotStore remove returns False for unknown decision
Given a standalone snapshot store
When I remove snapshot for decision "UNKNOWN"
Then the dsvc snapshot remove result should be False
Scenario: SnapshotStore get returns None for unknown decision
Given a standalone snapshot store
When I get snapshot for decision "UNKNOWN"
Then the dsvc standalone snapshot should be None
Scenario: SnapshotStore get_by_hash returns empty for unknown hash
Given a standalone snapshot store
When I query the standalone store by hash "sha256:nope"
Then the dsvc standalone hash query should return 0 decision IDs
Scenario: DecisionService string type coercion works
When I record a decision for plan "P1" with string type "strategy_choice"
Then the dsvc decision type should be "strategy_choice"
# --- Edge-case coverage ---
Scenario: Snapshot stored without a hash skips hash index
Given a standalone snapshot store
And a context snapshot with no hash
When I store the hashless snapshot for decision "D1"
And I get standalone snapshot for decision "D1"
Then the dsvc standalone retrieved snapshot should not be None
And the dsvc standalone retrieved snapshot hash should be empty
Scenario: Snapshot store remove cleans up hash index
Given a standalone snapshot store
And a context snapshot with hash "sha256:removeme" and ref "ref"
When I store the explicit snapshot for decision "D_REM"
And I remove snapshot for decision "D_REM"
Then the dsvc snapshot remove result should be True
And the dsvc standalone hash query for "sha256:removeme" should return 0 decision IDs
Scenario: Snapshot store remove last entry deletes hash bucket
Given a standalone snapshot store
And a context snapshot with hash "sha256:onlyone" and ref "ref"
When I store the explicit snapshot for decision "D_ONLY"
And I remove snapshot for decision "D_ONLY"
Then the dsvc snapshot remove result should be True
And the dsvc standalone hash query for "sha256:onlyone" should return 0 decision IDs
Scenario: Snapshot store remove snapshot with no hash
Given a standalone snapshot store
And a context snapshot with no hash
When I store the hashless snapshot for decision "D_NOHASH"
And I remove snapshot for decision "D_NOHASH"
Then the dsvc snapshot remove result should be True
Scenario: Snapshot store remove with multiple entries keeps remaining
Given a standalone snapshot store
And a context snapshot with hash "sha256:shared" and ref "ref"
When I store the explicit snapshot for decision "D_KEEP1"
And I store the explicit snapshot for decision "D_KEEP2"
And I remove snapshot for decision "D_KEEP1"
Then the dsvc snapshot remove result should be True
And the dsvc standalone hash query for "sha256:shared" should return 1 decision IDs
Scenario: Get tree when no decision is a root returns all decisions
When I record a decision for plan "P1" with a forced non-root parent
And I get the tree for plan "P1"
Then the dsvc tree should have 1 decisions
Scenario: List by type with string coercion filters correctly
When I record a prompt_definition decision for plan "P1" with question "Type1"
And I record a strategy_choice decision for plan "P1" with question "Type2"
And I filter decisions for plan "P1" by type "prompt_definition"
Then the dsvc decision list should have 1 entries
Scenario: Recording with whitespace-only plan_id raises ValidationError
When I try to record a decision with whitespace plan_id
Then a dsvc validation error should be raised
And the dsvc error should mention "plan_id"
Scenario: Recording with whitespace-only question raises ValidationError
When I try to record a decision with whitespace question
Then a dsvc validation error should be raised
And the dsvc error should mention "question"
Scenario: Recording with whitespace-only chosen_option raises ValidationError
When I try to record a decision with whitespace chosen_option
Then a dsvc validation error should be raised
And the dsvc error should mention "chosen_option"
Scenario: SnapshotStore list_for_plan skips missing snapshots
Given a standalone snapshot store
When I list snapshots for decisions with missing entries
Then the dsvc standalone plan snapshots should have 0 entries
# --- Persisted-mode (database-backed) ---
Scenario: Persisted record and get decision round-trips through database
Given a persisted decision service
When I record a prompt_definition decision for plan "P1" with question "Persisted root"
And I get the decision by its ID
Then the dsvc retrieved decision should match the recorded decision
Scenario: Persisted get non-existent decision raises DecisionNotFoundError
Given a persisted decision service
When I try to get decision "01NONEXISTENT00000000000000"
Then a dsvc decision not found error should be raised
Scenario: Persisted list decisions returns ordered results from database
Given a persisted decision service
When I record a prompt_definition decision for plan "P1" with question "First persisted"
And I record a strategy_choice decision for plan "P1" with question "Second persisted"
And I list decisions for plan "P1"
Then the dsvc decision list should have 2 entries
And the dsvc decision list should be ordered by sequence number
Scenario: Persisted list by type filters through database
Given a persisted decision service
When I record a prompt_definition decision for plan "P1" with question "Root"
And I record a strategy_choice decision for plan "P1" with question "Strategy"
And I record a strategy_choice decision for plan "P1" with question "Strategy 2"
And I filter decisions for plan "P1" by type "strategy_choice"
Then the dsvc decision list should have 2 entries
Scenario: Persisted get path to root navigates through database
Given a persisted decision service
When I record a prompt_definition decision for plan "P1" with question "Root"
And I record a strategy_choice decision for plan "P1" with parent as child "Level 1"
And I record an implementation_choice decision for plan "P1" with second parent as child "Level 2"
And I get the path to root from the last decision
Then the dsvc path should have 3 decisions
And the dsvc last path decision should be the root
Scenario: Persisted get superseded returns superseded decisions from database
Given a persisted decision service
When I record a prompt_definition decision for plan "P1" with question "Original"
And I record a strategy_choice decision for plan "P1" with question "Replacement"
And I mark the first decision as superseded by the second
And I get superseded decisions for plan "P1"
Then the dsvc superseded list should have 1 entry
Scenario: Persisted mark superseded updates database and cache
Given a persisted decision service
When I record a prompt_definition decision for plan "P1" with question "Old"
And I record a strategy_choice decision for plan "P1" with question "New"
And I mark the first decision as superseded by the second
Then the dsvc first decision should be superseded
And the dsvc first decision superseded_by should match the second decision
Scenario: Persisted delete removes decision from database
Given a persisted decision service
When I record a prompt_definition decision for plan "P1" with question "Ephemeral"
And I delete the recorded decision
Then the dsvc decision should be deleted
And the dsvc plan "P1" should have 0 decisions
Scenario: Persisted duplicate decision raises DuplicateDecisionError
Given a persisted decision service
When I record a prompt_definition decision for plan "P1" with question "Original"
And I try to store a duplicate of the recorded decision
Then a dsvc duplicate decision error should be raised
Scenario: Persisted sequence resumes after service restart
Given a persisted decision service
When I record a prompt_definition decision for plan "P1" with question "Before restart"
And I record a strategy_choice decision for plan "P1" with question "Also before restart"
Then the dsvc decisions should have sequence numbers 0 1
When I recreate the decision service with the same database
And I record a strategy_choice decision for plan "P1" with question "After restart"
Then the dsvc decision sequence number should be 2
And the dsvc next sequence for plan "P1" should be 3
+22 -54
View File
@@ -22,18 +22,15 @@ Feature: DecisionService application-layer coverage
Scenario: dsvc- record_decision persists and returns the decision
Given dsvc- a DecisionService with a mocked UnitOfWork
And dsvc- a sample root Decision object
When dsvc- I call record_decision with the sample decision
When dsvc- I call record_decision with plan_id and required args
Then dsvc- the UoW transaction should have been entered
And dsvc- ctx.decisions.create should have been called with the decision
And dsvc- the returned decision should be the same object
And dsvc- ctx.decisions.create should have been called once
And dsvc- the returned decision should have the correct plan_id
Scenario: dsvc- record_decision logs info and debug messages
Scenario: dsvc- record_decision logs info on success
Given dsvc- a DecisionService with a mocked UnitOfWork and captured logger
And dsvc- a sample root Decision object
When dsvc- I call record_decision with the sample decision
Then dsvc- the logger should have recorded an info call with "recording_decision"
And dsvc- the logger should have recorded a debug call with "decision_recorded"
When dsvc- I call record_decision with plan_id and required args
Then dsvc- the logger should have recorded an info call with "decision.recorded"
# ------------------------------------------------------------------
# get_decision
@@ -46,57 +43,38 @@ Feature: DecisionService application-layer coverage
Then dsvc- ctx.decisions.get should have been called with the ID
And dsvc- the returned value should be the expected Decision
Scenario: dsvc- get_decision returns None when not found
Scenario: dsvc- get_decision raises DecisionNotFoundError when not found
Given dsvc- a DecisionService with a mocked UnitOfWork
And dsvc- the mock repo get method returns None
When dsvc- I call get_decision with an unknown ID
Then dsvc- the returned value should be None
Scenario: dsvc- get_decision logs a debug message
Given dsvc- a DecisionService with a mocked UnitOfWork and captured logger
And dsvc- the mock repo get method returns a Decision
When dsvc- I call get_decision with a known ID
Then dsvc- the logger should have recorded a debug call with "getting_decision"
When dsvc- I call get_decision with an unknown ID expecting not-found
Then dsvc- a DecisionNotFoundError should have been raised
# ------------------------------------------------------------------
# get_decisions_for_plan
# list_decisions
# ------------------------------------------------------------------
Scenario: dsvc- get_decisions_for_plan returns list of decisions
Scenario: dsvc- list_decisions returns list of decisions
Given dsvc- a DecisionService with a mocked UnitOfWork
And dsvc- the mock repo get_by_plan method returns 3 decisions
When dsvc- I call get_decisions_for_plan with a plan ID
When dsvc- I call list_decisions with a plan ID
Then dsvc- ctx.decisions.get_by_plan should have been called with the plan ID
And dsvc- the returned list should have 3 decisions
Scenario: dsvc- get_decisions_for_plan returns empty list when none exist
Scenario: dsvc- list_decisions returns empty list when none exist
Given dsvc- a DecisionService with a mocked UnitOfWork
And dsvc- the mock repo get_by_plan method returns 0 decisions
When dsvc- I call get_decisions_for_plan with a plan ID
When dsvc- I call list_decisions with a plan ID
Then dsvc- the returned list should have 0 decisions
Scenario: dsvc- get_decisions_for_plan logs a debug message
Given dsvc- a DecisionService with a mocked UnitOfWork and captured logger
And dsvc- the mock repo get_by_plan method returns 3 decisions
When dsvc- I call get_decisions_for_plan with a plan ID
Then dsvc- the logger should have recorded a debug call with "getting_decisions_for_plan"
# ------------------------------------------------------------------
# get_decision_tree
# get_tree
# ------------------------------------------------------------------
Scenario: dsvc- get_decision_tree returns BFS-ordered list
Scenario: dsvc- get_tree returns BFS-ordered list
Given dsvc- a DecisionService with a mocked UnitOfWork
And dsvc- the mock repo get_tree method returns 4 decisions
When dsvc- I call get_decision_tree with a root ID
Then dsvc- ctx.decisions.get_tree should have been called with the root ID
And dsvc- the returned tree list should have 4 decisions
Scenario: dsvc- get_decision_tree logs a debug message
Given dsvc- a DecisionService with a mocked UnitOfWork and captured logger
And dsvc- the mock repo get_tree method returns 4 decisions
When dsvc- I call get_decision_tree with a root ID
Then dsvc- the logger should have recorded a debug call with "getting_decision_tree"
And dsvc- the mock repo get_by_plan method returns 4 decisions as a tree
When dsvc- I call get_tree with a plan ID
Then dsvc- the returned tree list should have 4 decisions
# ------------------------------------------------------------------
# get_path_to_root
@@ -109,18 +87,13 @@ Feature: DecisionService application-layer coverage
Then dsvc- ctx.decisions.get_path_to_root should have been called with the leaf ID
And dsvc- the returned path list should have 3 decisions
Scenario: dsvc- get_path_to_root logs a debug message
Given dsvc- a DecisionService with a mocked UnitOfWork and captured logger
And dsvc- the mock repo get_path_to_root method returns 3 decisions
When dsvc- I call get_path_to_root with a leaf ID
Then dsvc- the logger should have recorded a debug call with "getting_path_to_root"
# ------------------------------------------------------------------
# mark_superseded
# ------------------------------------------------------------------
Scenario: dsvc- mark_superseded updates and returns the decision
Given dsvc- a DecisionService with a mocked UnitOfWork
And dsvc- the mock repo has the replacement decision in cache
And dsvc- the mock repo update_superseded_by method returns a superseded Decision
When dsvc- I call mark_superseded with old and new IDs
Then dsvc- ctx.decisions.update_superseded_by should have been called with both IDs
@@ -128,9 +101,10 @@ Feature: DecisionService application-layer coverage
Scenario: dsvc- mark_superseded logs info with both IDs
Given dsvc- a DecisionService with a mocked UnitOfWork and captured logger
And dsvc- the mock repo has the replacement decision in cache
And dsvc- the mock repo update_superseded_by method returns a superseded Decision
When dsvc- I call mark_superseded with old and new IDs
Then dsvc- the logger should have recorded an info call with "marking_superseded"
Then dsvc- the logger should have recorded an info call with "decision.superseded"
# ------------------------------------------------------------------
# list_by_type
@@ -148,9 +122,3 @@ Feature: DecisionService application-layer coverage
And dsvc- the mock repo list_by_type method returns 0 decisions
When dsvc- I call list_by_type with plan ID and type "error_recovery"
Then dsvc- the returned type list should have 0 decisions
Scenario: dsvc- list_by_type logs a debug message
Given dsvc- a DecisionService with a mocked UnitOfWork and captured logger
And dsvc- the mock repo list_by_type method returns 2 decisions
When dsvc- I call list_by_type with plan ID and type "strategy_choice"
Then dsvc- the logger should have recorded a debug call with "listing_by_type"
+27 -29
View File
@@ -10,16 +10,33 @@ existing step definitions.
from __future__ import annotations
import os
from unittest.mock import MagicMock
from typing import TYPE_CHECKING
from unittest.mock import create_autospec
from behave import given, then, when
from behave.runner import Context
if TYPE_CHECKING:
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------
def _make_test_uow() -> UnitOfWork:
"""Create a UnitOfWork backed by an in-memory SQLite for testing.
Uses ``UnitOfWork.__init__`` + ``init_database()`` rather than the
``__new__()`` anti-pattern to stay in sync with UoW internals.
"""
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
uow = UnitOfWork("sqlite:///:memory:")
uow.init_database()
return uow
def _ensure_test_db_env(context: Context) -> None:
"""Set up a test database URL if not already set."""
if not hasattr(context, "_decdi_env_saved"):
@@ -81,34 +98,15 @@ def step_check_decision_service(context: Context) -> None:
@given("decdi- a PlanLifecycleService with a DecisionService")
def step_create_lifecycle_with_decision(context: Context) -> None:
"""Build an in-memory PlanLifecycleService wired with DecisionService."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.decision_service import DecisionService
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
from cleveragents.config.settings import Settings
# Build a fresh in-memory DB
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
uow = _make_test_uow()
# Create a UoW that skips migration
uow = UnitOfWork.__new__(UnitOfWork)
uow.database_url = "sqlite:///:memory:"
uow._engine = engine
uow._session_factory = sessionmaker(
bind=engine,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
uow._database_initialized = True
uow._prompt_for_migration = None
mock_settings = MagicMock()
mock_settings = create_autospec(Settings, instance=True)
mock_settings.database_url = "sqlite:///:memory:"
decision_svc = DecisionService(settings=mock_settings, unit_of_work=uow)
@@ -143,12 +141,12 @@ def step_start_strategize(context: Context) -> None:
@then("decdi- a strategy_choice decision should be recorded for the plan")
def step_check_strategize_decision(context: Context) -> None:
decisions = context.decision_svc.get_decisions_for_plan(context.plan_id)
decisions = context.decision_svc.list_decisions(context.plan_id)
strategy_decisions = [
d for d in decisions if str(d.decision_type) == "strategy_choice"
]
assert len(strategy_decisions) >= 1, (
f"Expected at least 1 strategy_choice decision, got {len(strategy_decisions)}"
assert len(strategy_decisions) == 1, (
f"Expected exactly 1 strategy_choice decision, got {len(strategy_decisions)}"
)
@@ -192,12 +190,12 @@ def step_start_execute(context: Context) -> None:
@then("decdi- an implementation_choice decision should be recorded for the plan")
def step_check_execute_decision(context: Context) -> None:
decisions = context.decision_svc.get_decisions_for_plan(context.plan_id)
decisions = context.decision_svc.list_decisions(context.plan_id)
impl_decisions = [
d for d in decisions if str(d.decision_type) == "implementation_choice"
]
assert len(impl_decisions) >= 1, (
f"Expected at least 1 implementation_choice decision, got {len(impl_decisions)}"
assert len(impl_decisions) == 1, (
f"Expected exactly 1 implementation_choice decision, got {len(impl_decisions)}"
)
File diff suppressed because it is too large Load Diff
@@ -18,7 +18,10 @@ from behave import given, then, when
from behave.runner import Context
from ulid import ULID
from cleveragents.application.services.decision_service import DecisionService
from cleveragents.application.services.decision_service import (
DecisionNotFoundError,
DecisionService,
)
from cleveragents.domain.models.core.decision import (
Decision,
DecisionType,
@@ -126,7 +129,6 @@ def step_dsvc_check_uow(context: Context) -> None:
@then("dsvc- the service should have a bound structlog logger")
def step_dsvc_check_logger(context: Context) -> None:
# The _logger is a BoundLogger with service="decision"
assert hasattr(context.dsvc_service, "_logger"), "Service missing _logger attribute"
@@ -174,37 +176,34 @@ def step_dsvc_service_with_logger(context: Context) -> None:
# ---------------------------------------------------------------------------
@given("dsvc- a sample root Decision object")
def step_dsvc_sample_decision(context: Context) -> None:
context.dsvc_decision = _make_decision(plan_id=_PLAN_ID)
@when("dsvc- I call record_decision with the sample decision")
@when("dsvc- I call record_decision with plan_id and required args")
def step_dsvc_call_record(context: Context) -> None:
context.dsvc_result = context.dsvc_service.record_decision(
context.dsvc_decision,
plan_id=_PLAN_ID,
decision_type=DecisionType.PROMPT_DEFINITION,
question="Which approach?",
chosen_option="REST API",
)
@then("dsvc- the UoW transaction should have been entered")
def step_dsvc_txn_entered(context: Context) -> None:
# The fact that ctx.decisions.create was called proves we entered
# the transaction context manager successfully. We verify that
# the mock context's decisions repo was accessed.
assert context.dsvc_decisions.create.called, (
"Transaction context was not entered — create was never called"
)
@then("dsvc- ctx.decisions.create should have been called with the decision")
@then("dsvc- ctx.decisions.create should have been called once")
def step_dsvc_create_called(context: Context) -> None:
context.dsvc_decisions.create.assert_called_once_with(context.dsvc_decision)
assert context.dsvc_decisions.create.call_count == 1, (
f"Expected create to be called once, got {context.dsvc_decisions.create.call_count}"
)
@then("dsvc- the returned decision should be the same object")
def step_dsvc_record_returns_same(context: Context) -> None:
assert context.dsvc_result is context.dsvc_decision, (
"record_decision should return the same Decision instance"
@then("dsvc- the returned decision should have the correct plan_id")
def step_dsvc_record_returns_correct_plan(context: Context) -> None:
assert context.dsvc_result.plan_id == _PLAN_ID, (
f"Expected plan_id '{_PLAN_ID}', got '{context.dsvc_result.plan_id}'"
)
@@ -220,13 +219,6 @@ def step_dsvc_logger_info(context: Context, event: str) -> None:
assert event in events, f"Expected info event '{event}' in {events}"
@then('dsvc- the logger should have recorded a debug call with "{event}"')
def step_dsvc_logger_debug(context: Context, event: str) -> None:
debug_calls = context.dsvc_logger.debug.call_args_list
events = [c.args[0] if c.args else c.kwargs.get("event", "") for c in debug_calls]
assert event in events, f"Expected debug event '{event}' in {events}"
# ---------------------------------------------------------------------------
# get_decision
# ---------------------------------------------------------------------------
@@ -251,9 +243,13 @@ def step_dsvc_call_get(context: Context) -> None:
context.dsvc_result = context.dsvc_service.get_decision(_DECISION_ID)
@when("dsvc- I call get_decision with an unknown ID")
@when("dsvc- I call get_decision with an unknown ID expecting not-found")
def step_dsvc_call_get_unknown(context: Context) -> None:
context.dsvc_result = context.dsvc_service.get_decision("NONEXISTENT_ID_12345678")
try:
context.dsvc_service.get_decision("NONEXISTENT_ID_12345678")
context.dsvc_error = None
except DecisionNotFoundError as exc:
context.dsvc_error = exc
@then("dsvc- ctx.decisions.get should have been called with the ID")
@@ -268,13 +264,14 @@ def step_dsvc_get_returns_expected(context: Context) -> None:
)
@then("dsvc- the returned value should be None")
def step_dsvc_result_is_none(context: Context) -> None:
assert context.dsvc_result is None, f"Expected None, got {context.dsvc_result!r}"
@then("dsvc- a DecisionNotFoundError should have been raised")
def step_dsvc_not_found_raised(context: Context) -> None:
assert context.dsvc_error is not None, "Expected DecisionNotFoundError but got None"
assert isinstance(context.dsvc_error, DecisionNotFoundError)
# ---------------------------------------------------------------------------
# get_decisions_for_plan
# list_decisions
# ---------------------------------------------------------------------------
@@ -297,9 +294,9 @@ def step_dsvc_mock_get_by_plan(context: Context, count: int) -> None:
context.dsvc_expected_count = count
@when("dsvc- I call get_decisions_for_plan with a plan ID")
def step_dsvc_call_get_for_plan(context: Context) -> None:
context.dsvc_result = context.dsvc_service.get_decisions_for_plan(_PLAN_ID)
@when("dsvc- I call list_decisions with a plan ID")
def step_dsvc_call_list_decisions(context: Context) -> None:
context.dsvc_result = context.dsvc_service.list_decisions(_PLAN_ID)
@then("dsvc- ctx.decisions.get_by_plan should have been called with the plan ID")
@@ -315,34 +312,48 @@ def step_dsvc_list_count(context: Context, count: int) -> None:
# ---------------------------------------------------------------------------
# get_decision_tree
# get_tree
# ---------------------------------------------------------------------------
@given("dsvc- the mock repo get_tree method returns {count:d} decisions")
def step_dsvc_mock_get_tree(context: Context, count: int) -> None:
root = _make_decision(decision_id=_ROOT_ID, plan_id=_PLAN_ID)
decisions = [root] + [
_make_decision(
plan_id=_PLAN_ID,
sequence_number=i,
decision_type=DecisionType.STRATEGY_CHOICE,
parent_decision_id=_ROOT_ID,
)
for i in range(1, count)
]
context.dsvc_decisions.get_tree.return_value = decisions
context.dsvc_expected_tree_count = count
_CHILD1_ID = str(ULID())
_CHILD2_ID = str(ULID())
_GRANDCHILD_ID = str(ULID())
@when("dsvc- I call get_decision_tree with a root ID")
@given("dsvc- the mock repo get_by_plan method returns 4 decisions as a tree")
def step_dsvc_mock_get_tree(context: Context) -> None:
root = _make_decision(
decision_id=_ROOT_ID,
plan_id=_PLAN_ID,
sequence_number=0,
)
child1 = _make_decision(
decision_id=_CHILD1_ID,
plan_id=_PLAN_ID,
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
parent_decision_id=_ROOT_ID,
)
child2 = _make_decision(
decision_id=_CHILD2_ID,
plan_id=_PLAN_ID,
sequence_number=2,
decision_type=DecisionType.STRATEGY_CHOICE,
parent_decision_id=_ROOT_ID,
)
grandchild = _make_decision(
decision_id=_GRANDCHILD_ID,
plan_id=_PLAN_ID,
sequence_number=3,
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
parent_decision_id=_CHILD1_ID,
)
context.dsvc_decisions.get_by_plan.return_value = [root, child1, child2, grandchild]
@when("dsvc- I call get_tree with a plan ID")
def step_dsvc_call_get_tree(context: Context) -> None:
context.dsvc_result = context.dsvc_service.get_decision_tree(_ROOT_ID)
@then("dsvc- ctx.decisions.get_tree should have been called with the root ID")
def step_dsvc_get_tree_called(context: Context) -> None:
context.dsvc_decisions.get_tree.assert_called_once_with(_ROOT_ID)
context.dsvc_result = context.dsvc_service.get_tree(_PLAN_ID)
@then("dsvc- the returned tree list should have {count:d} decisions")
@@ -398,6 +409,23 @@ def step_dsvc_path_count(context: Context, count: int) -> None:
# ---------------------------------------------------------------------------
@given("dsvc- the mock repo has the replacement decision in cache")
def step_dsvc_seed_replacement_in_cache(context: Context) -> None:
"""Seed the replacement decision into the service's in-memory cache.
``mark_superseded`` now validates that ``new_decision_id`` exists
before proceeding. We add the replacement to ``_decisions`` so
the validation lookup succeeds.
"""
replacement = _make_decision(
decision_id=_NEW_DECISION_ID,
plan_id=_PLAN_ID,
sequence_number=99,
decision_type=DecisionType.STRATEGY_CHOICE,
)
context.dsvc_service._decisions[_NEW_DECISION_ID] = replacement
@given("dsvc- the mock repo update_superseded_by method returns a superseded Decision")
def step_dsvc_mock_superseded(context: Context) -> None:
context.dsvc_superseded_decision = _make_decision(
@@ -217,7 +217,7 @@ def step_pec_mock_tree_decisions(context: Context) -> None:
context.pec_plan_id = str(ULID())
decisions = _make_tree_decisions()
svc = MagicMock()
svc.get_decisions_for_plan.return_value = decisions
svc.list_decisions.return_value = decisions
context.pec_container = _mock_container_with_decision_svc(svc)
@@ -226,7 +226,7 @@ def step_pec_mock_deep_decisions(context: Context) -> None:
context.pec_plan_id = str(ULID())
decisions = _make_deep_decisions()
svc = MagicMock()
svc.get_decisions_for_plan.return_value = decisions
svc.list_decisions.return_value = decisions
context.pec_container = _mock_container_with_decision_svc(svc)
@@ -260,7 +260,7 @@ def step_pec_mock_superseded_decisions(context: Context) -> None:
),
]
svc = MagicMock()
svc.get_decisions_for_plan.return_value = decisions
svc.list_decisions.return_value = decisions
context.pec_container = _mock_container_with_decision_svc(svc)
@@ -268,7 +268,7 @@ def step_pec_mock_superseded_decisions(context: Context) -> None:
def step_pec_mock_empty_decisions(context: Context) -> None:
context.pec_plan_id = str(ULID())
svc = MagicMock()
svc.get_decisions_for_plan.return_value = []
svc.list_decisions.return_value = []
context.pec_container = _mock_container_with_decision_svc(svc)
+58
View File
@@ -0,0 +1,58 @@
*** Settings ***
Documentation Smoke tests for DecisionService recording and snapshot store
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_decision_recording.py
*** Test Cases ***
Record And Retrieve Decision
[Documentation] Record a decision via DecisionService and retrieve by ID
[Tags] service decision recording
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} record-retrieve cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} record-retrieve-ok
Record Multiple With Sequencing
[Documentation] Record 3 decisions and verify monotonic sequence numbers
[Tags] service decision sequencing
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} sequencing cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} sequencing-ok
Snapshot Auto-Capture
[Documentation] Record a decision and verify context snapshot is auto-captured
[Tags] service decision snapshot
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} snapshot-capture cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} snapshot-capture-ok
Decision Tree BFS Via Service
[Documentation] Build a 3-node tree and verify BFS order via get_tree
[Tags] service decision tree
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} tree-bfs cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tree-bfs-ok
Mark Decision Superseded Via Service
[Documentation] Mark a decision as superseded via the service layer
[Tags] service decision superseded
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} superseded cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} superseded-ok
Delete Decision Via Service
[Documentation] Delete a decision and verify snapshot is also removed
[Tags] service decision delete
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} delete cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} delete-ok
Snapshot Hash Deduplication
[Documentation] Verify hash-based deduplication in SnapshotStore
[Tags] service decision snapshot dedup
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} snapshot-dedup cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} snapshot-dedup-ok
+9 -23
View File
@@ -13,19 +13,15 @@ from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock
from unittest.mock import create_autospec
# Ensure src is importable when run from workspace root
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from sqlalchemy import create_engine # noqa: I001
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.decision_service import DecisionService
from cleveragents.application.services.decision_service import DecisionService # noqa: I001
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
@@ -36,20 +32,8 @@ from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
def _make_uow() -> UnitOfWork:
"""Create a UoW backed by an in-memory SQLite database."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
uow = UnitOfWork.__new__(UnitOfWork)
uow.database_url = "sqlite:///:memory:"
uow._engine = engine
uow._session_factory = sessionmaker(
bind=engine,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
uow._database_initialized = True
uow._prompt_for_migration = None
uow = UnitOfWork("sqlite:///:memory:")
uow.init_database()
return uow
@@ -84,7 +68,9 @@ def _resolve_service() -> None:
def _record_integration() -> None:
"""Verify decision recording during plan lifecycle transitions."""
uow = _make_uow()
mock_settings = MagicMock()
from cleveragents.config.settings import Settings
mock_settings = create_autospec(Settings, instance=True)
mock_settings.database_url = "sqlite:///:memory:"
decision_svc = DecisionService(settings=mock_settings, unit_of_work=uow)
@@ -108,7 +94,7 @@ def _record_integration() -> None:
# Start strategize -> should record a strategy_choice decision
lifecycle_svc.start_strategize(plan_id)
decisions = decision_svc.get_decisions_for_plan(plan_id)
decisions = decision_svc.list_decisions(plan_id)
strategy_decisions = [
d for d in decisions if str(d.decision_type) == "strategy_choice"
]
@@ -128,7 +114,7 @@ def _record_integration() -> None:
# Start execute -> should record an implementation_choice decision
lifecycle_svc.start_execute(plan_id)
decisions = decision_svc.get_decisions_for_plan(plan_id)
decisions = decision_svc.list_decisions(plan_id)
impl_decisions = [
d for d in decisions if str(d.decision_type) == "implementation_choice"
]
+245
View File
@@ -0,0 +1,245 @@
"""Helper script for Robot Framework decision recording smoke tests.
Usage:
python robot/helper_decision_recording.py <subcommand>
Subcommands:
record-retrieve Record + retrieve a decision via DecisionService
sequencing Verify monotonic sequencing
snapshot-capture Verify auto-captured context snapshot
tree-bfs Build a tree and verify BFS via get_tree
superseded Mark a decision as superseded via service
delete Delete a decision and verify snapshot removal
snapshot-dedup Verify hash-based deduplication in SnapshotStore
"""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure src is importable when run from workspace root
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.application.services.decision_service import (
DecisionService,
SnapshotStore,
)
from cleveragents.domain.models.core.decision import (
ContextSnapshot,
DecisionType,
)
_PLAN_ID = "01HV000000000000000000RS01"
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def _record_retrieve():
svc = DecisionService()
d = svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.PROMPT_DEFINITION,
question="What approach?",
chosen_option="Build API",
)
got = svc.get_decision(d.decision_id)
assert got.decision_id == d.decision_id
assert got.is_root
assert str(got.decision_type) == "prompt_definition"
print("record-retrieve-ok")
def _sequencing():
svc = DecisionService()
d0 = svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.PROMPT_DEFINITION,
question="First",
chosen_option="Chosen first",
)
d1 = svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Second",
chosen_option="Chosen second",
parent_decision_id=d0.decision_id,
)
d2 = svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
question="Third",
chosen_option="Chosen third",
parent_decision_id=d0.decision_id,
)
assert d0.sequence_number == 0
assert d1.sequence_number == 1
assert d2.sequence_number == 2
assert svc.count_decisions(_PLAN_ID) == 3
print("sequencing-ok")
def _snapshot_capture():
svc = DecisionService()
d = svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Snapshot test",
chosen_option="Chosen",
)
snap = svc.get_snapshot(d.decision_id)
assert snap is not None
assert snap.hot_context_hash.startswith("sha256:")
print("snapshot-capture-ok")
def _tree_bfs():
svc = DecisionService()
root = svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.PROMPT_DEFINITION,
question="Root",
chosen_option="Root choice",
)
child1 = svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Child 1",
chosen_option="C1",
parent_decision_id=root.decision_id,
)
svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Child 2",
chosen_option="C2",
parent_decision_id=root.decision_id,
)
svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
question="Grandchild",
chosen_option="GC",
parent_decision_id=child1.decision_id,
)
tree = svc.get_tree(_PLAN_ID)
assert len(tree) == 4, f"expected 4, got {len(tree)}"
assert tree[0].is_root
# Verify BFS level order: compute depths and check non-decreasing.
by_id = {d.decision_id: d for d in tree}
def _depth(node):
depth = 0
current = node
while current.parent_decision_id is not None:
parent = by_id.get(current.parent_decision_id)
if parent is None:
break
depth += 1
current = parent
return depth
depths = [_depth(d) for d in tree]
for i in range(1, len(depths)):
assert depths[i] >= depths[i - 1], (
f"BFS order violated at index {i}: "
f"depth {depths[i]} follows depth {depths[i - 1]}. "
f"Depths: {depths}"
)
print("tree-bfs-ok")
def _superseded():
svc = DecisionService()
d1 = svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.PROMPT_DEFINITION,
question="Original",
chosen_option="Orig",
)
d2 = svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.STRATEGY_CHOICE,
question="Replacement",
chosen_option="New",
parent_decision_id=d1.decision_id,
)
updated = svc.mark_superseded(d1.decision_id, d2.decision_id)
assert updated.is_superseded
assert updated.superseded_by == d2.decision_id
superseded = svc.get_superseded(_PLAN_ID)
assert len(superseded) == 1
print("superseded-ok")
def _delete():
svc = DecisionService()
d = svc.record_decision(
plan_id=_PLAN_ID,
decision_type=DecisionType.PROMPT_DEFINITION,
question="Ephemeral",
chosen_option="Gone",
)
did = d.decision_id
assert svc.get_snapshot(did) is not None
result = svc.delete_decision(did)
assert result is True
assert svc.get_snapshot(did) is None
assert svc.count_decisions(_PLAN_ID) == 0
print("delete-ok")
def _snapshot_dedup():
store = SnapshotStore()
snap = ContextSnapshot(
hot_context_hash="sha256:samehash",
hot_context_ref="ref1",
)
store.store("DEC_A", snap)
store.store("DEC_B", snap)
ids = store.get_by_hash("sha256:samehash")
assert len(ids) == 2, f"expected 2, got {len(ids)}"
assert "DEC_A" in ids
assert "DEC_B" in ids
# Remove one
store.remove("DEC_A")
ids2 = store.get_by_hash("sha256:samehash")
assert len(ids2) == 1
assert "DEC_B" in ids2
print("snapshot-dedup-ok")
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS = {
"record-retrieve": _record_retrieve,
"sequencing": _sequencing,
"snapshot-capture": _snapshot_capture,
"tree-bfs": _tree_bfs,
"superseded": _superseded,
"delete": _delete,
"snapshot-dedup": _snapshot_dedup,
}
def main():
if len(sys.argv) < 2:
raise SystemExit(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>")
command = sys.argv[1]
handler = _COMMANDS.get(command)
if handler is None:
raise SystemExit(f"Unknown command: {command}")
handler()
if __name__ == "__main__":
main()
@@ -16,7 +16,11 @@ from cleveragents.application.services.correction_service import (
CorrectionService,
)
from cleveragents.application.services.decision_service import (
DecisionNotFoundError,
DecisionService,
DuplicateDecisionError,
SequenceConflictError,
SnapshotStore,
)
from cleveragents.application.services.invariant_service import (
InvariantService,
@@ -117,9 +121,11 @@ __all__ = [
"ConfigService",
"ContextFragment",
"CorrectionService",
"DecisionNotFoundError",
"DecisionService",
"DefaultValidationRunner",
"DependencyCycleRule",
"DuplicateDecisionError",
"DuplicateImportRule",
"FileMergeOutcome",
"InvariantService",
@@ -140,8 +146,10 @@ __all__ = [
"SemanticValidationRule",
"SemanticValidationService",
"SemanticValidationSeverity",
"SequenceConflictError",
"SkeletonCompressorService",
"SkillRegistryService",
"SnapshotStore",
"SpawnEntry",
"SpawnMetadata",
"SpawnResult",
@@ -1,210 +1,772 @@
"""Application-layer service for decision tree operations.
"""Decision recording and snapshot store service.
``DecisionService`` wraps the :class:`DecisionRepository` behind a
thin application-layer façade, adding structured logging, transaction
management via :class:`UnitOfWork`, and a consistent API surface for
callers (e.g. ``PlanLifecycleService``).
``DecisionService`` provides a clean interface for recording decisions
during plan execution, retrieving decision histories, and managing
context snapshots.
Dual-mode persistence
---------------------
| Mode | UnitOfWork | Storage |
|------------|------------|-----------------------------------------|
| In-memory | ``None`` | ``self._decisions`` / ``self._snapshots`` dicts |
| Persisted | provided | DB via ``DecisionRepository`` + in-memory cache |
In persisted mode the in-memory dicts act as a write-through cache:
mutations are written to DB first, then the cache is updated.
Based on:
- ADR-007 (Repository Pattern / Unit of Work)
- ADR-033 (Decision Recording Protocol)
- Forgejo issue #173 (Wire decision services into DI)
- Forgejo issue #172
- docs/specification.md L18345-L18521
- docs/adr/ADR-033-decision-recording-protocol.md
"""
from __future__ import annotations
import hashlib
import json
from collections import deque
from typing import TYPE_CHECKING
import structlog
from cleveragents.domain.models.core.decision import Decision
from cleveragents.core.exceptions import (
BusinessRuleViolation,
ResourceNotFoundError,
ValidationError,
)
from cleveragents.domain.models.core.decision import (
ArtifactRef,
ContextSnapshot,
Decision,
DecisionType,
)
if TYPE_CHECKING:
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
logger = structlog.get_logger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: Maximum length for ``actor_reasoning`` (raw LLM trace). Prevents
#: unbounded storage from a misbehaving model while remaining generous
#: enough for typical chain-of-thought traces.
_MAX_ACTOR_REASONING: int = 100_000
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class DuplicateDecisionError(BusinessRuleViolation):
"""Raised when a decision with the same ID already exists."""
def __init__(self, decision_id: str) -> None:
super().__init__(f"Decision '{decision_id}' already exists")
self.decision_id = decision_id
class DecisionNotFoundError(ResourceNotFoundError):
"""Raised when a decision cannot be found."""
def __init__(self, decision_id: str) -> None:
super().__init__(
resource_type="decision",
resource_id=decision_id,
)
class SequenceConflictError(BusinessRuleViolation):
"""Raised when a sequence number is already in use for a plan."""
def __init__(self, plan_id: str, sequence_number: int) -> None:
super().__init__(
f"Sequence number {sequence_number} already exists for plan '{plan_id}'"
)
self.plan_id = plan_id
self.sequence_number = sequence_number
# ---------------------------------------------------------------------------
# Snapshot store
# ---------------------------------------------------------------------------
class SnapshotStore:
"""In-memory store for context snapshots keyed by decision_id.
Snapshots are stored alongside decisions but this dedicated store
provides hash-based deduplication and retrieval helpers.
"""
def __init__(self) -> None:
self._snapshots: dict[str, ContextSnapshot] = {}
self._hash_index: dict[str, list[str]] = {}
def store(self, decision_id: str, snapshot: ContextSnapshot) -> None:
"""Store a snapshot for a given decision.
Args:
decision_id: ULID of the decision.
snapshot: The context snapshot to store.
"""
self._snapshots[decision_id] = snapshot
h = snapshot.hot_context_hash
if h:
self._hash_index.setdefault(h, []).append(decision_id)
def get(self, decision_id: str) -> ContextSnapshot | None:
"""Retrieve a snapshot by decision ID.
Returns:
The snapshot, or ``None`` if not found.
"""
return self._snapshots.get(decision_id)
def get_by_hash(self, context_hash: str) -> list[str]:
"""Return decision IDs sharing the same context hash.
Args:
context_hash: The hot_context_hash to look up.
Returns:
List of decision IDs with matching hash.
"""
return list(self._hash_index.get(context_hash, []))
def remove(self, decision_id: str) -> bool:
"""Remove a snapshot.
Returns:
``True`` if a snapshot was removed, ``False`` otherwise.
"""
snapshot = self._snapshots.pop(decision_id, None)
if snapshot is None:
return False
h = snapshot.hot_context_hash
if h and h in self._hash_index:
ids = self._hash_index[h]
if decision_id in ids:
ids.remove(decision_id)
if not ids:
del self._hash_index[h]
return True
def list_for_plan(self, decisions: list[Decision]) -> dict[str, ContextSnapshot]:
"""Return snapshots for a list of decisions.
Args:
decisions: Decisions to look up snapshots for.
Returns:
Dict mapping decision_id snapshot for found entries.
"""
result: dict[str, ContextSnapshot] = {}
for d in decisions:
snap = self._snapshots.get(d.decision_id)
if snap is not None:
result[d.decision_id] = snap
return result
# ---------------------------------------------------------------------------
# Decision service
# ---------------------------------------------------------------------------
class DecisionService:
"""Coordinate decision persistence through the Unit of Work.
"""Service for recording decisions and managing context snapshots.
All public methods open a transaction, delegate to
:class:`DecisionRepository`, and commit. Callers do not need to
manage sessions directly.
Args:
settings: Application settings (unused currently but kept for
consistency with the service constructor convention).
unit_of_work: A :class:`UnitOfWork` instance used to open
transactional scopes.
Provides record, list, tree, and path-to-root helpers for the
decision subsystem. Supports dual-mode persistence: in-memory
when no UnitOfWork is provided, or DB-backed via
``DecisionRepository`` when a UnitOfWork is wired.
"""
def __init__(
self,
settings: object,
unit_of_work: UnitOfWork,
settings: Settings | None = None,
unit_of_work: UnitOfWork | None = None,
) -> None:
"""Initialize the decision service.
Args:
settings: Application settings (optional for in-memory mode).
unit_of_work: Unit of Work for database transactions.
When provided, decisions are persisted via
``DecisionRepository``. When ``None``, the service
falls back to in-memory storage.
"""
self.settings = settings
self.unit_of_work = unit_of_work
self._logger = logger.bind(service="decision")
# In-memory storage (used as cache in persisted mode,
# or as primary storage when no UoW)
self._decisions: dict[str, Decision] = {}
self._plan_decisions: dict[str, list[str]] = {}
self._plan_sequence: dict[str, int] = {}
self._sequence_initialised: set[str] = set()
# Snapshot store
self.snapshots = SnapshotStore()
@property
def _persisted(self) -> bool:
"""Return True when a UnitOfWork is wired for persistence."""
return self.unit_of_work is not None
# ------------------------------------------------------------------
# record_decision
# Recording
# ------------------------------------------------------------------
def record_decision(self, decision: Decision) -> Decision:
"""Persist a new decision.
def record_decision(
self,
plan_id: str,
decision_type: DecisionType | str,
question: str,
chosen_option: str,
*,
parent_decision_id: str | None = None,
alternatives_considered: list[str] | None = None,
confidence_score: float | None = None,
rationale: str = "",
actor_reasoning: str | None = None,
context_snapshot: ContextSnapshot | None = None,
artifacts_produced: list[ArtifactRef] | None = None,
is_correction: bool = False,
corrects_decision_id: str | None = None,
correction_reason: str | None = None,
) -> Decision:
"""Record a new decision in the plan's decision tree.
Opens a UnitOfWork transaction, creates the decision via the
repository, and commits.
Automatically assigns a monotonically increasing sequence number,
generates a decision ID (ULID), and captures a context snapshot.
Args:
decision: The :class:`Decision` domain object to persist.
plan_id: ULID of the plan.
decision_type: Type of decision being recorded.
question: What question was being answered.
chosen_option: The option that was chosen.
parent_decision_id: Optional parent in the decision tree.
alternatives_considered: Other options evaluated.
confidence_score: Confidence in the decision (0.0-1.0).
rationale: Human-readable rationale.
actor_reasoning: Raw LLM reasoning trace.
context_snapshot: Snapshot of context at decision time.
artifacts_produced: Artifacts created as side-effects.
is_correction: Whether this corrects another decision.
corrects_decision_id: ULID of the decision being corrected.
correction_reason: Why the correction was made.
Returns:
The same :class:`Decision` instance (pass-through).
The recorded :class:`Decision`.
Raises:
ValidationError: If required fields are missing or invalid.
DuplicateDecisionError: If a decision with the same ID exists.
DatabaseError: On transient or unexpected DB errors.
"""
self._logger.info(
"recording_decision",
decision_id=decision.decision_id,
plan_id=decision.plan_id,
decision_type=str(decision.decision_type),
sequence_number=decision.sequence_number,
if not plan_id or not plan_id.strip():
raise ValidationError("plan_id must not be empty")
if not question or not question.strip():
raise ValidationError("question must not be empty")
if not chosen_option or not chosen_option.strip():
raise ValidationError("chosen_option must not be empty")
if actor_reasoning is not None and len(actor_reasoning) > _MAX_ACTOR_REASONING:
raise ValidationError(
f"actor_reasoning exceeds maximum length of {_MAX_ACTOR_REASONING}"
)
# Coerce string to enum if needed
if isinstance(decision_type, str):
decision_type = DecisionType(decision_type)
# Auto-assign sequence number
seq = self._next_sequence(plan_id)
# Build context snapshot
snapshot = context_snapshot or ContextSnapshot()
if not snapshot.hot_context_hash:
# Generate a hash from the question + chosen_option as a
# minimal snapshot when no explicit hash is provided
snapshot = self._auto_capture_snapshot(question, chosen_option, snapshot)
decision = Decision(
plan_id=plan_id,
decision_type=decision_type,
sequence_number=seq,
question=question,
chosen_option=chosen_option,
parent_decision_id=parent_decision_id,
alternatives_considered=alternatives_considered or [],
confidence_score=confidence_score,
rationale=rationale,
actor_reasoning=actor_reasoning,
context_snapshot=snapshot,
artifacts_produced=artifacts_produced or [],
is_correction=is_correction,
corrects_decision_id=corrects_decision_id,
correction_reason=correction_reason,
)
with self.unit_of_work.transaction() as ctx:
ctx.decisions.create(decision)
self._logger.debug(
"decision_recorded",
self._store_decision(decision)
self._logger.info(
"decision.recorded",
decision_id=decision.decision_id,
plan_id=plan_id,
decision_type=str(decision_type),
sequence=seq,
)
return decision
# ------------------------------------------------------------------
# get_decision
# Retrieval
# ------------------------------------------------------------------
def get_decision(self, decision_id: str) -> Decision | None:
"""Retrieve a single decision by its ULID.
def get_decision(self, decision_id: str) -> Decision:
"""Retrieve a single decision by ID.
Args:
decision_id: ULID of the decision.
Returns:
The :class:`Decision` or ``None`` if not found.
The :class:`Decision`.
Raises:
DecisionNotFoundError: If the decision does not exist.
"""
self._logger.debug("getting_decision", decision_id=decision_id)
with self.unit_of_work.transaction() as ctx:
return ctx.decisions.get(decision_id)
if self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
result = ctx.decisions.get(decision_id)
if result is None:
raise DecisionNotFoundError(decision_id)
return result
decision = self._decisions.get(decision_id)
if decision is None:
raise DecisionNotFoundError(decision_id)
return decision
# ------------------------------------------------------------------
# get_decisions_for_plan
# ------------------------------------------------------------------
def get_decisions_for_plan(self, plan_id: str) -> list[Decision]:
"""Return all decisions for a plan ordered by sequence number.
def list_decisions(self, plan_id: str) -> list[Decision]:
"""List all decisions for a plan, ordered by sequence number.
Args:
plan_id: ULID of the plan.
Returns:
List of :class:`Decision` instances.
List of decisions sorted by sequence_number.
"""
self._logger.debug("getting_decisions_for_plan", plan_id=plan_id)
with self.unit_of_work.transaction() as ctx:
return ctx.decisions.get_by_plan(plan_id)
if self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
return ctx.decisions.get_by_plan(plan_id)
# ------------------------------------------------------------------
# get_decision_tree
# ------------------------------------------------------------------
ids = self._plan_decisions.get(plan_id, [])
decisions = [self._decisions[did] for did in ids if did in self._decisions]
return sorted(decisions, key=lambda d: d.sequence_number)
def get_decision_tree(self, root_id: str) -> list[Decision]:
"""Retrieve the full decision tree rooted at *root_id* (BFS order).
def list_by_type(
self, plan_id: str, decision_type: DecisionType | str
) -> list[Decision]:
"""List decisions for a plan filtered by type.
Args:
root_id: ULID of the root decision.
plan_id: ULID of the plan.
decision_type: The type to filter by.
Returns:
List of :class:`Decision` instances (BFS order).
Raises:
DecisionNotFoundError: If the root decision does not exist.
Filtered list sorted by sequence_number.
"""
self._logger.debug("getting_decision_tree", root_id=root_id)
with self.unit_of_work.transaction() as ctx:
return ctx.decisions.get_tree(root_id)
if isinstance(decision_type, str):
decision_type = DecisionType(decision_type)
if self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
return ctx.decisions.list_by_type(plan_id, str(decision_type))
all_decisions = self.list_decisions(plan_id)
return [d for d in all_decisions if d.decision_type == decision_type]
# ------------------------------------------------------------------
# get_path_to_root
# Tree operations
# ------------------------------------------------------------------
def get_tree(self, plan_id: str) -> list[Decision]:
"""Get the full decision tree for a plan via BFS from root.
Returns decisions in BFS order (root first, then children
level by level).
Args:
plan_id: ULID of the plan.
Returns:
List of decisions in BFS order.
"""
all_decisions = self.list_decisions(plan_id)
if not all_decisions:
return []
# Find root(s) — decisions with no parent
roots = [d for d in all_decisions if d.is_root]
if not roots:
return all_decisions
# Build adjacency: parent_id → children
children_map: dict[str | None, list[Decision]] = {}
for d in all_decisions:
children_map.setdefault(d.parent_decision_id, []).append(d)
# BFS from roots
result: list[Decision] = []
queue: deque[Decision] = deque(roots)
visited: set[str] = set()
while queue:
node = queue.popleft()
if node.decision_id in visited:
continue
visited.add(node.decision_id)
result.append(node)
children = children_map.get(node.decision_id, [])
for child in sorted(children, key=lambda c: c.sequence_number):
if child.decision_id not in visited:
queue.append(child)
# Include orphaned subtrees (decisions unreachable from any root).
# These can arise when a parent decision is deleted. They are
# appended in sequence-number order after the BFS-reachable nodes.
if len(visited) < len(all_decisions):
orphans = [d for d in all_decisions if d.decision_id not in visited]
result.extend(orphans)
return result
def get_path_to_root(self, decision_id: str) -> list[Decision]:
"""Walk from *decision_id* up to the root (leaf → root order).
"""Navigate from a decision up to the tree root.
Returns the path from the given decision to the root,
inclusive of both endpoints.
Args:
decision_id: ULID of the starting decision.
Returns:
List of :class:`Decision` instances (leaf first).
List from the given decision up to the root.
Raises:
DecisionNotFoundError: If the starting decision is missing.
DecisionNotFoundError: If the starting decision does not exist.
"""
self._logger.debug("getting_path_to_root", decision_id=decision_id)
with self.unit_of_work.transaction() as ctx:
return ctx.decisions.get_path_to_root(decision_id)
if self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
return ctx.decisions.get_path_to_root(decision_id)
# ------------------------------------------------------------------
# mark_superseded
# ------------------------------------------------------------------
current = self._decisions.get(decision_id)
if current is None:
raise DecisionNotFoundError(decision_id)
def mark_superseded(
self,
decision_id: str,
new_decision_id: str,
) -> Decision:
"""Mark a decision as superseded by a new one.
path: list[Decision] = [current]
visited: set[str] = {current.decision_id}
Args:
decision_id: ULID of the decision to mark.
new_decision_id: ULID of the replacement decision.
while current.parent_decision_id is not None:
parent = self._decisions.get(current.parent_decision_id)
if parent is None or parent.decision_id in visited:
break
visited.add(parent.decision_id)
path.append(parent)
current = parent
Returns:
Updated :class:`Decision`.
return path
Raises:
DecisionNotFoundError: If *decision_id* does not exist.
"""
self._logger.info(
"marking_superseded",
decision_id=decision_id,
new_decision_id=new_decision_id,
)
with self.unit_of_work.transaction() as ctx:
return ctx.decisions.update_superseded_by(decision_id, new_decision_id)
# ------------------------------------------------------------------
# list_by_type
# ------------------------------------------------------------------
def list_by_type(self, plan_id: str, decision_type: str) -> list[Decision]:
"""List decisions of a given type for a plan.
def get_superseded(self, plan_id: str) -> list[Decision]:
"""Get all superseded decisions for a plan.
Args:
plan_id: ULID of the plan.
decision_type: String value of a :class:`DecisionType` enum
member (e.g. ``"strategy_choice"``).
Returns:
List of :class:`Decision` instances.
List of decisions that have been superseded.
"""
self._logger.debug(
"listing_by_type",
plan_id=plan_id,
decision_type=decision_type,
if self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
return ctx.decisions.get_superseded(plan_id)
all_decisions = self.list_decisions(plan_id)
return [d for d in all_decisions if d.is_superseded]
# ------------------------------------------------------------------
# Mutations
# ------------------------------------------------------------------
def mark_superseded(self, decision_id: str, new_decision_id: str) -> Decision:
"""Mark a decision as superseded by another.
Args:
decision_id: ULID of the decision to supersede.
new_decision_id: ULID of the replacement decision.
Returns:
The updated :class:`Decision` with superseded_by set.
Raises:
DecisionNotFoundError: If the decision or the replacement
decision does not exist.
"""
# Validate the replacement decision exists.
replacement = self._decisions.get(new_decision_id)
if replacement is None and self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
replacement = ctx.decisions.get(new_decision_id)
if replacement is None:
raise DecisionNotFoundError(new_decision_id)
if self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
result = ctx.decisions.update_superseded_by(
decision_id, new_decision_id
)
# Update cache
if decision_id in self._decisions:
self._decisions[decision_id] = result
self._logger.info(
"decision.superseded",
decision_id=decision_id,
superseded_by=new_decision_id,
)
return result
original = self._decisions.get(decision_id)
if original is None:
raise DecisionNotFoundError(decision_id)
updated = original.with_superseded_by(new_decision_id)
self._decisions[decision_id] = updated
self._logger.info(
"decision.superseded",
decision_id=decision_id,
superseded_by=new_decision_id,
)
with self.unit_of_work.transaction() as ctx:
return ctx.decisions.list_by_type(plan_id, decision_type)
return updated
def delete_decision(self, decision_id: str) -> bool:
"""Delete a decision.
Args:
decision_id: ULID of the decision to delete.
Returns:
``True`` if the decision was deleted.
Raises:
DecisionNotFoundError: If the decision does not exist.
"""
# Check cache first; in persisted mode also consult DB.
decision = self._decisions.get(decision_id)
if decision is None and self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
decision = ctx.decisions.get(decision_id)
if decision is None:
raise DecisionNotFoundError(decision_id)
# Delete from DB if persisted
if self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
ctx.decisions.delete(decision_id)
# Remove from cache
self._decisions.pop(decision_id, None)
plan_ids = self._plan_decisions.get(decision.plan_id, [])
if decision_id in plan_ids:
plan_ids.remove(decision_id)
self.snapshots.remove(decision_id)
self._logger.info("decision.deleted", decision_id=decision_id)
return True
# ------------------------------------------------------------------
# Snapshot helpers
# ------------------------------------------------------------------
def get_snapshot(self, decision_id: str) -> ContextSnapshot | None:
"""Retrieve the context snapshot for a decision.
Args:
decision_id: ULID of the decision.
Returns:
The snapshot, or ``None`` if not stored separately.
"""
return self.snapshots.get(decision_id)
def get_snapshots_for_plan(self, plan_id: str) -> dict[str, ContextSnapshot]:
"""Retrieve all snapshots for a plan's decisions.
Args:
plan_id: ULID of the plan.
Returns:
Dict mapping decision_id ContextSnapshot.
"""
decisions = self.list_decisions(plan_id)
return self.snapshots.list_for_plan(decisions)
# ------------------------------------------------------------------
# Statistics
# ------------------------------------------------------------------
def count_decisions(self, plan_id: str) -> int:
"""Return the number of decisions recorded for a plan.
In persisted mode uses ``SELECT COUNT(*)`` for efficiency.
Args:
plan_id: ULID of the plan.
Returns:
Decision count.
"""
if self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
return ctx.decisions.count(plan_id)
return len(self.list_decisions(plan_id))
def get_next_sequence(self, plan_id: str) -> int:
"""Return the next available sequence number for a plan.
This is useful for callers that need to know the next
sequence number before recording. Triggers DB rehydration
on the first call for a given *plan_id*.
Args:
plan_id: ULID of the plan.
Returns:
The next sequence number.
"""
if plan_id not in self._sequence_initialised:
self._rehydrate_sequence(plan_id)
self._sequence_initialised.add(plan_id)
return self._plan_sequence.get(plan_id, 0)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _next_sequence(self, plan_id: str) -> int:
"""Generate the next monotonically increasing sequence number.
On the first call for a given *plan_id* the counter is seeded from
the database (``MAX(sequence_number) + 1``) when a
:class:`UnitOfWork` is available. Subsequent calls for the same
plan use the in-memory counter.
Raises:
SequenceConflictError: If the generated sequence number is
already present in the in-memory cache (indicates a
concurrency or logic bug).
"""
if plan_id not in self._sequence_initialised:
self._rehydrate_sequence(plan_id)
self._sequence_initialised.add(plan_id)
seq = self._plan_sequence.get(plan_id, 0)
# Uniqueness guard: ensure no existing decision already holds
# this sequence number for the plan.
for did in self._plan_decisions.get(plan_id, []):
existing = self._decisions.get(did)
if existing is not None and existing.sequence_number == seq:
raise SequenceConflictError(plan_id, seq)
self._plan_sequence[plan_id] = seq + 1
return seq
def _rehydrate_sequence(self, plan_id: str) -> None:
"""Seed the in-memory sequence counter from the database.
When a :class:`UnitOfWork` is wired, queries
``MAX(sequence_number)`` for *plan_id* so that new decisions
continue the monotonic sequence after a service restart. When
no persistence layer is available the counter starts at ``0``
(or stays at its current value if already set).
"""
if plan_id in self._plan_sequence:
# Already has a counter — nothing to rehydrate.
return
if self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
max_seq = ctx.decisions.get_max_sequence_number(plan_id)
if max_seq is not None:
self._plan_sequence[plan_id] = max_seq + 1
return
# No DB or no existing rows — start from 0.
self._plan_sequence.setdefault(plan_id, 0)
def _store_decision(self, decision: Decision) -> None:
"""Store a decision in both persistence and cache."""
decision_id = decision.decision_id
plan_id = decision.plan_id
if decision_id in self._decisions:
raise DuplicateDecisionError(decision_id)
# Persist to DB if available
if self._persisted and self.unit_of_work is not None:
with self.unit_of_work.transaction() as ctx:
ctx.decisions.create(decision)
# Update in-memory cache
self._decisions[decision_id] = decision
self._plan_decisions.setdefault(plan_id, []).append(decision_id)
# Store snapshot
self.snapshots.store(decision_id, decision.context_snapshot)
@staticmethod
def _auto_capture_snapshot(
question: str,
chosen_option: str,
base_snapshot: ContextSnapshot,
) -> ContextSnapshot:
"""Generate a minimal context snapshot hash.
When no explicit ``hot_context_hash`` is provided, we compute
a SHA-256 hash from the question + chosen option as a minimal
content-addressable identifier.
Args:
question: The decision question.
chosen_option: The chosen option.
base_snapshot: The base snapshot to augment.
Returns:
A new :class:`ContextSnapshot` with the hash populated.
"""
content = json.dumps(
{"question": question, "chosen_option": chosen_option},
sort_keys=True,
)
context_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
return ContextSnapshot(
hot_context_hash=f"sha256:{context_hash}",
hot_context_ref=base_snapshot.hot_context_ref,
relevant_resources=list(base_snapshot.relevant_resources),
actor_state_ref=base_snapshot.actor_state_ref,
)
__all__ = [
"DecisionNotFoundError",
"DecisionService",
"DuplicateDecisionError",
"SequenceConflictError",
"SnapshotStore",
]
@@ -173,20 +173,11 @@ class PlanLifecycleService:
self._actions: dict[str, Action] = {}
self._plans: dict[str, Plan] = {}
# Decision sequence counter per plan (monotonic within a plan)
self._decision_seq: dict[str, int] = {}
@property
def _persisted(self) -> bool:
"""Return True when a UnitOfWork is wired for persistence."""
return self.unit_of_work is not None
def _next_seq(self, plan_id: str) -> int:
"""Return the next decision sequence number for *plan_id*."""
seq = self._decision_seq.get(plan_id, -1) + 1
self._decision_seq[plan_id] = seq
return seq
def _try_record_decision(
self,
plan_id: str,
@@ -203,19 +194,14 @@ class PlanLifecycleService:
if self.decision_service is None:
return
from cleveragents.domain.models.core.decision import Decision as DecisionModel
from cleveragents.domain.models.core.decision import DecisionType as DT
try:
decision = DecisionModel(
self.decision_service.record_decision(
plan_id=plan_id,
parent_decision_id=parent_decision_id,
sequence_number=self._next_seq(plan_id),
decision_type=DT(decision_type),
decision_type=decision_type,
question=question,
chosen_option=chosen_option,
parent_decision_id=parent_decision_id,
)
self.decision_service.record_decision(decision)
except Exception:
self._logger.warning(
"decision_recording_failed",
+1 -1
View File
@@ -2870,7 +2870,7 @@ def tree_decisions_cmd(
container = get_container()
svc: DecisionService = container.resolve(_DS)
decisions = svc.get_decisions_for_plan(plan_id)
decisions = svc.list_decisions(plan_id)
if not decisions:
console.print(f"No decisions found for plan '{plan_id}'.")
return
@@ -65,6 +65,7 @@ if TYPE_CHECKING:
from cleveragents.domain.models.core.checkpoint import Checkpoint
import structlog
from sqlalchemy import func as sa_func
from sqlalchemy.exc import DatabaseError as SQLAlchemyDatabaseError
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import Session
@@ -5172,6 +5173,68 @@ class DecisionRepository:
f"Failed to delete decision {decision_id}: {exc}",
) from exc
# --- SEQUENCE ----------------------------------------------------------
@database_retry
def get_max_sequence_number(self, plan_id: str) -> int | None:
"""Return the highest sequence number for *plan_id*, or ``None``.
Uses the composite index ``ix_decisions_plan_seq`` for an
efficient ``MAX()`` aggregate.
Args:
plan_id: ULID of the plan.
Returns:
The maximum ``sequence_number`` stored for the plan, or
``None`` when no decisions exist for the plan.
Raises:
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
result: int | None = (
session.query(sa_func.max(DecisionModel.sequence_number))
.filter(DecisionModel.plan_id == plan_id)
.scalar()
)
return result
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(
f"Failed to get max sequence number for plan {plan_id}: {exc}",
) from exc
# --- COUNT -------------------------------------------------------------
@database_retry
def count(self, plan_id: str) -> int:
"""Return the number of decisions for *plan_id*.
Uses ``COUNT(*)`` for efficiency instead of loading all rows.
Args:
plan_id: ULID of the plan.
Returns:
The decision count (``0`` when no decisions exist).
Raises:
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
result: int = (
session.query(sa_func.count(DecisionModel.decision_id))
.filter(DecisionModel.plan_id == plan_id)
.scalar()
) or 0
return result
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(
f"Failed to count decisions for plan {plan_id}: {exc}",
) from exc
# ---------------------------------------------------------------------------
# Checkpoint Repository (Stage M6 - checkpointing and rollback)
+2 -2
View File
@@ -359,8 +359,8 @@ decision_service # noqa: B018, F821
plan_lifecycle_service # noqa: B018, F821
record_decision # noqa: B018, F821
get_decision # noqa: B018, F821
get_decisions_for_plan # noqa: B018, F821
get_decision_tree # noqa: B018, F821
list_decisions # noqa: B018, F821
get_tree # noqa: B018, F821
get_path_to_root # noqa: B018, F821
mark_superseded # noqa: B018, F821
list_by_type # noqa: B018, F821