fix(resources): restore master-synced files to fix unit_tests CI failure
CI / security (pull_request) Failing after 1s
CI / quality (pull_request) Failing after 1s
CI / lint (pull_request) Failing after 1s
CI / unit_tests (pull_request) Failing after 1s
CI / typecheck (pull_request) Failing after 1s
CI / integration_tests (pull_request) Failing after 0s
CI / docker (pull_request) Has been skipped
CI / coverage (pull_request) Has been skipped
CI / e2e_tests (pull_request) Failing after 1s
CI / build (pull_request) Failing after 1s
CI / helm (pull_request) Failing after 1s
CI / push-validation (pull_request) Failing after 1s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 1h5m23s
CI / security (pull_request) Failing after 1s
CI / quality (pull_request) Failing after 1s
CI / lint (pull_request) Failing after 1s
CI / unit_tests (pull_request) Failing after 1s
CI / typecheck (pull_request) Failing after 1s
CI / integration_tests (pull_request) Failing after 0s
CI / docker (pull_request) Has been skipped
CI / coverage (pull_request) Has been skipped
CI / e2e_tests (pull_request) Failing after 1s
CI / build (pull_request) Failing after 1s
CI / helm (pull_request) Failing after 1s
CI / push-validation (pull_request) Failing after 1s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 1h5m23s
Restores files that were accidentally removed from the PR branch during previous sync attempts. The PR branch was missing decomposition_decision_correction and multi_project_sandbox feature files and their step definitions, and had reverted plan.py, decomposition_models.py, and decomposition_service.py to older versions that lacked _SandboxInfo, _route_sandbox_files_to_worktrees, DecisionCorrectionResult, and recompute_subtree. These omissions caused the unit_tests CI job to fail. All files are now synced with master; only the PR-specific resource type extension interface files differ from master.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
Feature: Decision correction with selective subtree recomputation
|
||||
As a plan orchestrator
|
||||
I want to recompute only the affected subtree when a decision is incorrect
|
||||
So that sibling branches and ancestors are preserved unchanged
|
||||
|
||||
Background:
|
||||
Given a decomposition service for correction
|
||||
And a decomposition result with a multi-level hierarchy
|
||||
|
||||
Scenario: Recompute subtree for a leaf node - only leaf is recomputed
|
||||
When I recompute the subtree for a leaf node
|
||||
Then the correction result should have recomputed nodes
|
||||
And the correction result should have preserved nodes
|
||||
And the target node should be in the recomputed set
|
||||
And sibling nodes should be in the preserved set
|
||||
|
||||
Scenario: Recompute subtree for a middle node - subtree is recomputed
|
||||
When I recompute the subtree for a middle node
|
||||
Then the correction result should have recomputed nodes
|
||||
And the correction result should have preserved nodes
|
||||
And the target node should be in the recomputed set
|
||||
And ancestor nodes should be in the preserved set
|
||||
|
||||
Scenario: Recompute subtree for root - all nodes are recomputed
|
||||
When I recompute the subtree for the root node
|
||||
Then the correction result should have recomputed nodes
|
||||
And the correction result should have no preserved nodes
|
||||
|
||||
Scenario: DecisionCorrectionResult tracks recomputed vs preserved nodes
|
||||
When I recompute the subtree for a middle node
|
||||
Then the DecisionCorrectionResult should have a target_node_id
|
||||
And the DecisionCorrectionResult should have recomputed_node_ids
|
||||
And the DecisionCorrectionResult should have preserved_node_ids
|
||||
And the DecisionCorrectionResult should have metrics
|
||||
|
||||
Scenario: Sibling branches are unaffected during selective recomputation
|
||||
When I recompute the subtree for a middle node
|
||||
Then sibling branches should not be in the recomputed set
|
||||
And sibling branches should be in the preserved set
|
||||
|
||||
Scenario: Recompute subtree with custom config
|
||||
When I recompute the subtree for a leaf node with custom config
|
||||
Then the correction result config should match the custom config
|
||||
|
||||
Scenario: Recompute subtree raises ValueError for unknown node
|
||||
When I recompute the subtree for an unknown node
|
||||
Then a decomp correction ValueError should be raised
|
||||
|
||||
Scenario: Recompute subtree preserves ancestor nodes
|
||||
When I recompute the subtree for a leaf node
|
||||
Then ancestor nodes should be in the preserved set
|
||||
|
||||
Scenario: Correction result metrics track recomputed and preserved counts
|
||||
When I recompute the subtree for a middle node
|
||||
Then the metrics should contain recomputed_count
|
||||
And the metrics should contain preserved_count
|
||||
And the metrics should contain subtree_size
|
||||
@@ -0,0 +1,63 @@
|
||||
@multi-project-sandbox
|
||||
Feature: Per-resource sandboxes for multi-project plans (#7270)
|
||||
Per spec §19310-19312, each resource gets its own sandbox and
|
||||
Apply commits each sandbox separately.
|
||||
|
||||
Scenario: Single-resource plan creates one sandbox for mps
|
||||
Given a temp git project "alpha" for mps
|
||||
And a mocked plan service linking project "alpha" for mps
|
||||
When I call _create_sandbox_for_plan for mps
|
||||
Then sandbox_infos should have 1 entry for mps
|
||||
And sandbox_root should be a directory for mps
|
||||
|
||||
Scenario: Multi-resource plan creates sandboxes for each resource for mps
|
||||
Given a temp git project "alpha" for mps
|
||||
And a temp git project "beta" for mps
|
||||
And a mocked plan service linking projects "alpha" and "beta" for mps
|
||||
When I call _create_sandbox_for_plan for mps
|
||||
Then sandbox_infos should have 2 entries for mps
|
||||
And each sandbox_info should have a different sandbox_path for mps
|
||||
|
||||
Scenario: Route files moves file to correct worktree for mps
|
||||
Given a temp git project named "alpha" containing "src/app.py" for mps
|
||||
And a temp git project named "beta" containing "src/api.py" for mps
|
||||
And a mocked plan service linking projects "alpha" and "beta" for mps
|
||||
And sandbox_infos for both projects for mps
|
||||
And a file "src/api.py" exists in the primary sandbox for mps
|
||||
When I call _route_sandbox_files_to_worktrees for mps
|
||||
Then "src/api.py" should exist in the beta sandbox for mps
|
||||
And "src/api.py" should not exist in the alpha sandbox for mps
|
||||
|
||||
Scenario: Route files preserves primary file when both projects share path for mps
|
||||
Given a temp git project named "alpha" containing "README.md" for mps
|
||||
And a temp git project named "beta" containing "README.md" for mps
|
||||
And a mocked plan service linking projects "alpha" and "beta" for mps
|
||||
And sandbox_infos for both projects for mps
|
||||
And the file "README.md" in the primary sandbox is overwritten with "ROUTED_CONTENT" for mps
|
||||
When I call _route_sandbox_files_to_worktrees for mps
|
||||
Then "README.md" in the alpha sandbox should contain "ROUTED_CONTENT" for mps
|
||||
And "README.md" in the beta sandbox should not contain "ROUTED_CONTENT" for mps
|
||||
|
||||
Scenario: Route files is a no-op for single resource for mps
|
||||
Given a temp git project named "alpha" containing "src/app.py" for mps
|
||||
And sandbox_infos with only one entry for mps
|
||||
And a file "src/app.py" exists in the primary sandbox for mps
|
||||
When I call _route_sandbox_files_to_worktrees for mps
|
||||
Then "src/app.py" should still exist in the alpha sandbox for mps
|
||||
|
||||
Scenario: Apply merges multiple worktrees separately for mps
|
||||
Given a temp git project "alpha" with a worktree branch for mps
|
||||
And a temp git project "beta" with a worktree branch for mps
|
||||
And a mocked plan service linking projects "alpha" and "beta" for mps
|
||||
When I call _apply_sandbox_changes for mps
|
||||
Then both projects should have the merged changes for mps
|
||||
And the console output should contain "Apply Summary" for mps
|
||||
|
||||
Scenario: Partial apply continues when one merge fails for mps
|
||||
Given a temp git project "alpha" with a worktree branch for mps
|
||||
And a temp git project "beta" with a conflicting worktree branch for mps
|
||||
And a mocked plan service linking projects "alpha" and "beta" for mps
|
||||
When I call _apply_sandbox_changes for mps
|
||||
Then alpha should have the merged changes for mps
|
||||
And beta should have the original content for mps
|
||||
And _apply_sandbox_changes should return False for mps
|
||||
@@ -0,0 +1,366 @@
|
||||
"""Step definitions for decomposition decision correction BDD tests.
|
||||
|
||||
Tests selective subtree recomputation for decision correction.
|
||||
All step names are prefixed with 'decomposition correction' to avoid
|
||||
AmbiguousStep conflicts with existing decomposition steps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.application.services.decomposition_models import (
|
||||
ClusterStrategy,
|
||||
DecisionCorrectionResult,
|
||||
DecompositionConfig,
|
||||
DecompositionNode,
|
||||
DecompositionResult,
|
||||
)
|
||||
from cleveragents.application.services.decomposition_service import (
|
||||
DecompositionService,
|
||||
)
|
||||
|
||||
|
||||
def _make_simple_hierarchy() -> DecompositionResult:
|
||||
"""Build a simple 3-level hierarchy for testing.
|
||||
|
||||
Structure:
|
||||
root (internal)
|
||||
├── middle_a (internal)
|
||||
│ ├── leaf_a1 (leaf)
|
||||
│ └── leaf_a2 (leaf)
|
||||
└── middle_b (internal)
|
||||
└── leaf_b1 (leaf)
|
||||
"""
|
||||
leaf_a1 = DecompositionNode(
|
||||
node_id="leaf_a1",
|
||||
parent_id="middle_a",
|
||||
depth=2,
|
||||
file_paths=["src/a/file1.py", "src/a/file2.py"],
|
||||
language=".py",
|
||||
directory_prefix="src/a",
|
||||
estimated_tokens=100,
|
||||
strategy=ClusterStrategy.DIRECTORY,
|
||||
children_ids=[],
|
||||
)
|
||||
leaf_a2 = DecompositionNode(
|
||||
node_id="leaf_a2",
|
||||
parent_id="middle_a",
|
||||
depth=2,
|
||||
file_paths=["src/a/file3.py", "src/a/file4.py"],
|
||||
language=".py",
|
||||
directory_prefix="src/a",
|
||||
estimated_tokens=100,
|
||||
strategy=ClusterStrategy.DIRECTORY,
|
||||
children_ids=[],
|
||||
)
|
||||
leaf_b1 = DecompositionNode(
|
||||
node_id="leaf_b1",
|
||||
parent_id="middle_b",
|
||||
depth=2,
|
||||
file_paths=["src/b/file1.py", "src/b/file2.py"],
|
||||
language=".py",
|
||||
directory_prefix="src/b",
|
||||
estimated_tokens=100,
|
||||
strategy=ClusterStrategy.DIRECTORY,
|
||||
children_ids=[],
|
||||
)
|
||||
middle_a = DecompositionNode(
|
||||
node_id="middle_a",
|
||||
parent_id="root",
|
||||
depth=1,
|
||||
file_paths=[
|
||||
"src/a/file1.py",
|
||||
"src/a/file2.py",
|
||||
"src/a/file3.py",
|
||||
"src/a/file4.py",
|
||||
],
|
||||
language=".py",
|
||||
directory_prefix="src/a",
|
||||
estimated_tokens=200,
|
||||
strategy=ClusterStrategy.DIRECTORY,
|
||||
children_ids=["leaf_a1", "leaf_a2"],
|
||||
)
|
||||
middle_b = DecompositionNode(
|
||||
node_id="middle_b",
|
||||
parent_id="root",
|
||||
depth=1,
|
||||
file_paths=["src/b/file1.py", "src/b/file2.py"],
|
||||
language=".py",
|
||||
directory_prefix="src/b",
|
||||
estimated_tokens=100,
|
||||
strategy=ClusterStrategy.DIRECTORY,
|
||||
children_ids=["leaf_b1"],
|
||||
)
|
||||
root = DecompositionNode(
|
||||
node_id="root",
|
||||
parent_id=None,
|
||||
depth=0,
|
||||
file_paths=[
|
||||
"src/a/file1.py",
|
||||
"src/a/file2.py",
|
||||
"src/a/file3.py",
|
||||
"src/a/file4.py",
|
||||
"src/b/file1.py",
|
||||
"src/b/file2.py",
|
||||
],
|
||||
language=".py",
|
||||
directory_prefix="src",
|
||||
estimated_tokens=300,
|
||||
strategy=ClusterStrategy.DIRECTORY,
|
||||
children_ids=["middle_a", "middle_b"],
|
||||
)
|
||||
return DecompositionResult(
|
||||
nodes=[leaf_a1, leaf_a2, leaf_b1, middle_a, middle_b, root],
|
||||
max_depth_reached=2,
|
||||
total_files=6,
|
||||
metrics={"total_nodes": 6, "leaf_nodes": 3, "max_depth": 2},
|
||||
)
|
||||
|
||||
|
||||
@given("a decomposition service for correction")
|
||||
def step_given_correction_service(context: Any) -> None:
|
||||
"""Set up a fresh DecompositionService for correction tests."""
|
||||
context.correction_svc = DecompositionService()
|
||||
context.correction_result = None
|
||||
context.correction_error = None
|
||||
|
||||
|
||||
@given("a decomposition result with a multi-level hierarchy")
|
||||
def step_given_hierarchy(context: Any) -> None:
|
||||
"""Build a simple multi-level decomposition hierarchy."""
|
||||
context.existing_result = _make_simple_hierarchy()
|
||||
|
||||
|
||||
@when("I recompute the subtree for a leaf node")
|
||||
def step_when_recompute_leaf(context: Any) -> None:
|
||||
"""Recompute the subtree for leaf_a1."""
|
||||
svc: DecompositionService = context.correction_svc
|
||||
context.target_node_id = "leaf_a1"
|
||||
context.correction_result = svc.recompute_subtree(
|
||||
node_id="leaf_a1",
|
||||
existing_result=context.existing_result,
|
||||
)
|
||||
|
||||
|
||||
@when("I recompute the subtree for a middle node")
|
||||
def step_when_recompute_middle(context: Any) -> None:
|
||||
"""Recompute the subtree for middle_a (includes leaf_a1 and leaf_a2)."""
|
||||
svc: DecompositionService = context.correction_svc
|
||||
context.target_node_id = "middle_a"
|
||||
context.correction_result = svc.recompute_subtree(
|
||||
node_id="middle_a",
|
||||
existing_result=context.existing_result,
|
||||
)
|
||||
|
||||
|
||||
@when("I recompute the subtree for the root node")
|
||||
def step_when_recompute_root(context: Any) -> None:
|
||||
"""Recompute the subtree for the root node (all nodes)."""
|
||||
svc: DecompositionService = context.correction_svc
|
||||
context.target_node_id = "root"
|
||||
context.correction_result = svc.recompute_subtree(
|
||||
node_id="root",
|
||||
existing_result=context.existing_result,
|
||||
)
|
||||
|
||||
|
||||
@when("I recompute the subtree for a leaf node with custom config")
|
||||
def step_when_recompute_leaf_custom_config(context: Any) -> None:
|
||||
"""Recompute the subtree for leaf_a1 with a custom config."""
|
||||
svc: DecompositionService = context.correction_svc
|
||||
context.custom_config = DecompositionConfig(max_depth=2, max_files_per_subplan=50)
|
||||
context.target_node_id = "leaf_a1"
|
||||
context.correction_result = svc.recompute_subtree(
|
||||
node_id="leaf_a1",
|
||||
existing_result=context.existing_result,
|
||||
config=context.custom_config,
|
||||
)
|
||||
|
||||
|
||||
@when("I recompute the subtree for an unknown node")
|
||||
def step_when_recompute_unknown(context: Any) -> None:
|
||||
"""Attempt to recompute a non-existent node."""
|
||||
svc: DecompositionService = context.correction_svc
|
||||
try:
|
||||
svc.recompute_subtree(
|
||||
node_id="nonexistent_node",
|
||||
existing_result=context.existing_result,
|
||||
)
|
||||
except ValueError as exc:
|
||||
context.correction_error = exc
|
||||
|
||||
|
||||
@then("the correction result should have recomputed nodes")
|
||||
def step_then_has_recomputed_nodes(context: Any) -> None:
|
||||
"""Check that the correction result has at least one recomputed node."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
assert result is not None, "Expected a correction result"
|
||||
assert len(result.recomputed_nodes) > 0, (
|
||||
f"Expected recomputed_nodes to be non-empty, got {result.recomputed_nodes}"
|
||||
)
|
||||
|
||||
|
||||
@then("the correction result should have preserved nodes")
|
||||
def step_then_has_preserved_nodes(context: Any) -> None:
|
||||
"""Check that the correction result has at least one preserved node."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
assert result is not None, "Expected a correction result"
|
||||
assert len(result.preserved_nodes) > 0, (
|
||||
f"Expected preserved_nodes to be non-empty, got {result.preserved_nodes}"
|
||||
)
|
||||
|
||||
|
||||
@then("the correction result should have no preserved nodes")
|
||||
def step_then_no_preserved_nodes(context: Any) -> None:
|
||||
"""Check that the correction result has no preserved nodes (root recomputation)."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
assert result is not None, "Expected a correction result"
|
||||
assert len(result.preserved_nodes) == 0, (
|
||||
f"Expected preserved_nodes to be empty, got {result.preserved_nodes}"
|
||||
)
|
||||
|
||||
|
||||
@then("the target node should be in the recomputed set")
|
||||
def step_then_target_in_recomputed(context: Any) -> None:
|
||||
"""Check that the target node ID is tracked in the result."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
assert result.target_node_id == context.target_node_id, (
|
||||
f"Expected target_node_id='{context.target_node_id}', "
|
||||
f"got '{result.target_node_id}'"
|
||||
)
|
||||
|
||||
|
||||
@then("sibling nodes should be in the preserved set")
|
||||
def step_then_siblings_preserved(context: Any) -> None:
|
||||
"""Check that sibling nodes are preserved when a leaf is recomputed."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
preserved_ids = result.preserved_node_ids
|
||||
assert (
|
||||
"leaf_a2" in preserved_ids
|
||||
or "middle_b" in preserved_ids
|
||||
or "leaf_b1" in preserved_ids
|
||||
), f"Expected sibling nodes in preserved set, got {preserved_ids}"
|
||||
|
||||
|
||||
@then("ancestor nodes should be in the preserved set")
|
||||
def step_then_ancestors_preserved(context: Any) -> None:
|
||||
"""Check that ancestor nodes are preserved."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
preserved_ids = result.preserved_node_ids
|
||||
assert (
|
||||
"root" in preserved_ids
|
||||
or "middle_a" in preserved_ids
|
||||
or "middle_b" in preserved_ids
|
||||
), f"Expected ancestor nodes in preserved set, got {preserved_ids}"
|
||||
|
||||
|
||||
@then("sibling branches should not be in the recomputed set")
|
||||
def step_then_siblings_not_recomputed(context: Any) -> None:
|
||||
"""Check that sibling branches are not recomputed."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
recomputed_ids = result.recomputed_node_ids
|
||||
assert "middle_b" not in recomputed_ids, (
|
||||
f"Expected 'middle_b' not in recomputed set, got {recomputed_ids}"
|
||||
)
|
||||
assert "leaf_b1" not in recomputed_ids, (
|
||||
f"Expected 'leaf_b1' not in recomputed set, got {recomputed_ids}"
|
||||
)
|
||||
|
||||
|
||||
@then("sibling branches should be in the preserved set")
|
||||
def step_then_siblings_in_preserved(context: Any) -> None:
|
||||
"""Check that sibling branches are in the preserved set."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
preserved_ids = result.preserved_node_ids
|
||||
assert "middle_b" in preserved_ids, (
|
||||
f"Expected 'middle_b' in preserved set, got {preserved_ids}"
|
||||
)
|
||||
assert "leaf_b1" in preserved_ids, (
|
||||
f"Expected 'leaf_b1' in preserved set, got {preserved_ids}"
|
||||
)
|
||||
|
||||
|
||||
@then("the DecisionCorrectionResult should have a target_node_id")
|
||||
def step_then_has_target_node_id(context: Any) -> None:
|
||||
"""Check that the result has a target_node_id."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
assert result.target_node_id is not None and result.target_node_id != "", (
|
||||
f"Expected non-empty target_node_id, got '{result.target_node_id}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the DecisionCorrectionResult should have recomputed_node_ids")
|
||||
def step_then_has_recomputed_node_ids(context: Any) -> None:
|
||||
"""Check that the result has recomputed_node_ids property."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
ids = result.recomputed_node_ids
|
||||
assert isinstance(ids, list), f"Expected list, got {type(ids)}"
|
||||
assert len(ids) > 0, f"Expected non-empty recomputed_node_ids, got {ids}"
|
||||
|
||||
|
||||
@then("the DecisionCorrectionResult should have preserved_node_ids")
|
||||
def step_then_has_preserved_node_ids(context: Any) -> None:
|
||||
"""Check that the result has preserved_node_ids property."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
ids = result.preserved_node_ids
|
||||
assert isinstance(ids, list), f"Expected list, got {type(ids)}"
|
||||
|
||||
|
||||
@then("the DecisionCorrectionResult should have metrics")
|
||||
def step_then_has_metrics(context: Any) -> None:
|
||||
"""Check that the result has metrics."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
assert isinstance(result.metrics, dict), (
|
||||
f"Expected dict, got {type(result.metrics)}"
|
||||
)
|
||||
assert len(result.metrics) > 0, f"Expected non-empty metrics, got {result.metrics}"
|
||||
|
||||
|
||||
@then("the correction result config should match the custom config")
|
||||
def step_then_config_matches(context: Any) -> None:
|
||||
"""Check that the correction result uses the custom config."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
assert result.config == context.custom_config, (
|
||||
f"Expected config={context.custom_config}, got {result.config}"
|
||||
)
|
||||
|
||||
|
||||
@then("a decomp correction ValueError should be raised")
|
||||
def step_then_value_error_raised(context: Any) -> None:
|
||||
"""Check that a ValueError was raised."""
|
||||
assert context.correction_error is not None, (
|
||||
"Expected a ValueError to be raised, but none was"
|
||||
)
|
||||
assert isinstance(context.correction_error, ValueError), (
|
||||
f"Expected ValueError, got {type(context.correction_error)}"
|
||||
)
|
||||
|
||||
|
||||
@then("the metrics should contain recomputed_count")
|
||||
def step_then_metrics_recomputed_count(context: Any) -> None:
|
||||
"""Check that metrics contains recomputed_count."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
assert "recomputed_count" in result.metrics, (
|
||||
f"Expected 'recomputed_count' in metrics, got {result.metrics}"
|
||||
)
|
||||
|
||||
|
||||
@then("the metrics should contain preserved_count")
|
||||
def step_then_metrics_preserved_count(context: Any) -> None:
|
||||
"""Check that metrics contains preserved_count."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
assert "preserved_count" in result.metrics, (
|
||||
f"Expected 'preserved_count' in metrics, got {result.metrics}"
|
||||
)
|
||||
|
||||
|
||||
@then("the metrics should contain subtree_size")
|
||||
def step_then_metrics_subtree_size(context: Any) -> None:
|
||||
"""Check that metrics contains subtree_size."""
|
||||
result: DecisionCorrectionResult = context.correction_result
|
||||
assert "subtree_size" in result.metrics, (
|
||||
f"Expected 'subtree_size' in metrics, got {result.metrics}"
|
||||
)
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Steps for multi_project_sandbox.feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
_PLAN_ID = "01TESTMULTIPROJ000000000000"
|
||||
|
||||
|
||||
def _git(args: list[str], cwd: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def _init_git_repo(path: str) -> None:
|
||||
_git(["init", "-q", "-b", "main"], path)
|
||||
_git(["config", "user.name", "T"], path)
|
||||
_git(["config", "user.email", "t@t"], path)
|
||||
_git(["config", "commit.gpgsign", "false"], path)
|
||||
|
||||
|
||||
# ── Given ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@given('a temp git project "{name}" for mps')
|
||||
def step_create_project(context: object, name: str) -> None:
|
||||
if not hasattr(context, "mps_projects"):
|
||||
context.mps_projects = {}
|
||||
d = tempfile.mkdtemp(prefix=f"mps-{name}-")
|
||||
context.add_cleanup(shutil.rmtree, d, True)
|
||||
_init_git_repo(d)
|
||||
Path(d, "README.md").write_text(f"# {name}\n")
|
||||
_git(["add", "."], d)
|
||||
_git(["commit", "-q", "-m", "init"], d)
|
||||
context.mps_projects[name] = d
|
||||
|
||||
|
||||
@given('a temp git project named "{name}" containing "{filename}" for mps')
|
||||
def step_create_project_with_file(context: object, name: str, filename: str) -> None:
|
||||
if not hasattr(context, "mps_projects"):
|
||||
context.mps_projects = {}
|
||||
d = tempfile.mkdtemp(prefix=f"mps-{name}-")
|
||||
context.add_cleanup(shutil.rmtree, d, True)
|
||||
_init_git_repo(d)
|
||||
fpath = os.path.join(d, filename)
|
||||
os.makedirs(os.path.dirname(fpath), exist_ok=True)
|
||||
Path(fpath).write_text(f"# {name} {filename}\n")
|
||||
_git(["add", "."], d)
|
||||
_git(["commit", "-q", "-m", "init"], d)
|
||||
context.mps_projects[name] = d
|
||||
|
||||
|
||||
@given('a temp git project "{name}" with a worktree branch for mps')
|
||||
def step_create_project_with_worktree(context: object, name: str) -> None:
|
||||
if not hasattr(context, "mps_projects"):
|
||||
context.mps_projects = {}
|
||||
d = tempfile.mkdtemp(prefix=f"mps-{name}-")
|
||||
context.add_cleanup(shutil.rmtree, d, True)
|
||||
_init_git_repo(d)
|
||||
Path(d, f"{name}.py").write_text(f"# original {name}\n")
|
||||
_git(["add", "."], d)
|
||||
_git(["commit", "-q", "-m", "init"], d)
|
||||
|
||||
branch = f"cleveragents/plan-{_PLAN_ID}"
|
||||
wt_dir = tempfile.mkdtemp(prefix=f"mps-wt-{name}-")
|
||||
context.add_cleanup(shutil.rmtree, wt_dir, True)
|
||||
_git(["worktree", "add", "-b", branch, wt_dir, "HEAD"], d)
|
||||
Path(wt_dir, f"{name}.py").write_text(f"# fixed {name}\n")
|
||||
_git(["add", "."], wt_dir)
|
||||
_git(["commit", "-q", "-m", f"fix {name}"], wt_dir)
|
||||
|
||||
context.mps_projects[name] = d
|
||||
|
||||
|
||||
@given('a temp git project "{name}" with a conflicting worktree branch for mps')
|
||||
def step_create_project_with_conflict(context: object, name: str) -> None:
|
||||
if not hasattr(context, "mps_projects"):
|
||||
context.mps_projects = {}
|
||||
d = tempfile.mkdtemp(prefix=f"mps-{name}-")
|
||||
context.add_cleanup(shutil.rmtree, d, True)
|
||||
_init_git_repo(d)
|
||||
Path(d, f"{name}.py").write_text(f"# original {name}\n")
|
||||
_git(["add", "."], d)
|
||||
_git(["commit", "-q", "-m", "init"], d)
|
||||
|
||||
branch = f"cleveragents/plan-{_PLAN_ID}"
|
||||
wt_dir = tempfile.mkdtemp(prefix=f"mps-wt-{name}-")
|
||||
context.add_cleanup(shutil.rmtree, wt_dir, True)
|
||||
_git(["worktree", "add", "-b", branch, wt_dir, "HEAD"], d)
|
||||
Path(wt_dir, f"{name}.py").write_text(f"# branch {name}\n")
|
||||
_git(["add", "."], wt_dir)
|
||||
_git(["commit", "-q", "-m", f"branch {name}"], wt_dir)
|
||||
|
||||
# Create conflict on main
|
||||
Path(d, f"{name}.py").write_text(f"# main {name}\n")
|
||||
_git(["add", "."], d)
|
||||
_git(["commit", "-q", "-m", f"main {name}"], d)
|
||||
|
||||
context.mps_projects[name] = d
|
||||
|
||||
|
||||
def _build_mocks(context: object, project_names: list[str]) -> tuple:
|
||||
"""Build mock service + container for the given projects."""
|
||||
links = []
|
||||
resources = {}
|
||||
for name in project_names:
|
||||
rid = f"res-mps-{name}"
|
||||
mock_lr = MagicMock()
|
||||
mock_lr.resource_id = rid
|
||||
links.append((name, mock_lr))
|
||||
|
||||
mock_resource = MagicMock()
|
||||
mock_resource.resource_type_name = "git-checkout"
|
||||
mock_resource.location = context.mps_projects[name]
|
||||
mock_resource.resource_id = rid
|
||||
resources[rid] = mock_resource
|
||||
|
||||
mock_plan = MagicMock()
|
||||
mock_plan.project_links = [MagicMock(project_name=name) for name, _ in links]
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.get_plan.return_value = mock_plan
|
||||
|
||||
mock_projects = {}
|
||||
for name, lr in links:
|
||||
mock_proj = MagicMock()
|
||||
mock_proj.linked_resources = [lr]
|
||||
mock_projects[name] = mock_proj
|
||||
|
||||
mock_project_repo = MagicMock()
|
||||
mock_project_repo.get.side_effect = lambda n: mock_projects.get(n)
|
||||
|
||||
mock_resource_registry = MagicMock()
|
||||
mock_resource_registry.show_resource.side_effect = lambda rid: resources[rid]
|
||||
|
||||
mock_container = MagicMock()
|
||||
mock_container.namespaced_project_repo.return_value = mock_project_repo
|
||||
mock_container.resource_registry_service.return_value = mock_resource_registry
|
||||
|
||||
context.mps_service = mock_service
|
||||
context.mps_container = mock_container
|
||||
return mock_service, mock_container
|
||||
|
||||
|
||||
@given('a mocked plan service linking project "{name}" for mps')
|
||||
def step_mock_single(context: object, name: str) -> None:
|
||||
_build_mocks(context, [name])
|
||||
|
||||
|
||||
@given('a mocked plan service linking projects "{a}" and "{b}" for mps')
|
||||
def step_mock_multi(context: object, a: str, b: str) -> None:
|
||||
_build_mocks(context, [a, b])
|
||||
|
||||
|
||||
@given("sandbox_infos for both projects for mps")
|
||||
def step_create_sandbox_infos(context: object) -> None:
|
||||
from cleveragents.cli.commands.plan import _create_sandbox_for_plan
|
||||
|
||||
with patch(
|
||||
"cleveragents.application.container.get_container",
|
||||
return_value=context.mps_container,
|
||||
):
|
||||
context.mps_sandbox_root, context.mps_sandbox_infos = _create_sandbox_for_plan(
|
||||
_PLAN_ID, context.mps_service
|
||||
)
|
||||
for info in context.mps_sandbox_infos:
|
||||
context.add_cleanup(info.sandbox_obj.cleanup)
|
||||
|
||||
|
||||
@given("sandbox_infos with only one entry for mps")
|
||||
def step_create_single_sandbox_info(context: object) -> None:
|
||||
names = list(context.mps_projects.keys())
|
||||
_build_mocks(context, [names[0]])
|
||||
|
||||
from cleveragents.cli.commands.plan import _create_sandbox_for_plan
|
||||
|
||||
with patch(
|
||||
"cleveragents.application.container.get_container",
|
||||
return_value=context.mps_container,
|
||||
):
|
||||
context.mps_sandbox_root, context.mps_sandbox_infos = _create_sandbox_for_plan(
|
||||
_PLAN_ID, context.mps_service
|
||||
)
|
||||
for info in context.mps_sandbox_infos:
|
||||
context.add_cleanup(info.sandbox_obj.cleanup)
|
||||
|
||||
|
||||
@given('a file "{filename}" exists in the primary sandbox for mps')
|
||||
def step_write_file_to_primary(context: object, filename: str) -> None:
|
||||
primary = context.mps_sandbox_infos[0]
|
||||
fpath = os.path.join(primary.sandbox_path, filename)
|
||||
os.makedirs(os.path.dirname(fpath), exist_ok=True)
|
||||
Path(fpath).write_text("# routed content\n")
|
||||
|
||||
|
||||
# ── When ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I call _create_sandbox_for_plan for mps")
|
||||
def step_call_create(context: object) -> None:
|
||||
from cleveragents.cli.commands.plan import _create_sandbox_for_plan
|
||||
|
||||
with patch(
|
||||
"cleveragents.application.container.get_container",
|
||||
return_value=context.mps_container,
|
||||
):
|
||||
context.mps_sandbox_root, context.mps_sandbox_infos = _create_sandbox_for_plan(
|
||||
_PLAN_ID, context.mps_service
|
||||
)
|
||||
for info in context.mps_sandbox_infos:
|
||||
context.add_cleanup(info.sandbox_obj.cleanup)
|
||||
|
||||
|
||||
@when("I call _route_sandbox_files_to_worktrees for mps")
|
||||
def step_call_route(context: object) -> None:
|
||||
from cleveragents.cli.commands.plan import _route_sandbox_files_to_worktrees
|
||||
|
||||
_route_sandbox_files_to_worktrees(context.mps_sandbox_infos)
|
||||
|
||||
|
||||
@when("I call _apply_sandbox_changes for mps")
|
||||
def step_call_apply(context: object) -> None:
|
||||
from rich.console import Console
|
||||
|
||||
from cleveragents.cli.commands.plan import _apply_sandbox_changes
|
||||
|
||||
output = StringIO()
|
||||
console = Console(file=output, width=200)
|
||||
|
||||
with patch(
|
||||
"cleveragents.application.container.get_container",
|
||||
return_value=context.mps_container,
|
||||
):
|
||||
context.mps_apply_result = _apply_sandbox_changes(
|
||||
_PLAN_ID,
|
||||
context.mps_service,
|
||||
console,
|
||||
)
|
||||
context.mps_console_output = output.getvalue()
|
||||
|
||||
|
||||
# ── Then ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@then("sandbox_infos should have {count:d} entry for mps")
|
||||
@then("sandbox_infos should have {count:d} entries for mps")
|
||||
def step_check_count(context: object, count: int) -> None:
|
||||
assert len(context.mps_sandbox_infos) == count, (
|
||||
f"Expected {count} sandbox_infos, got {len(context.mps_sandbox_infos)}"
|
||||
)
|
||||
|
||||
|
||||
@then("sandbox_root should be a directory for mps")
|
||||
def step_check_root_dir(context: object) -> None:
|
||||
assert os.path.isdir(context.mps_sandbox_root), (
|
||||
f"sandbox_root is not a directory: {context.mps_sandbox_root}"
|
||||
)
|
||||
|
||||
|
||||
@then("each sandbox_info should have a different sandbox_path for mps")
|
||||
def step_check_unique_paths(context: object) -> None:
|
||||
paths = [info.sandbox_path for info in context.mps_sandbox_infos]
|
||||
assert len(paths) == len(set(paths)), f"Duplicate sandbox paths: {paths}"
|
||||
|
||||
|
||||
@then('"{filename}" should exist in the beta sandbox for mps')
|
||||
def step_file_in_beta(context: object, filename: str) -> None:
|
||||
beta_info = context.mps_sandbox_infos[1]
|
||||
fpath = os.path.join(beta_info.sandbox_path, filename)
|
||||
assert os.path.isfile(fpath), f"{filename} not found in beta sandbox"
|
||||
|
||||
|
||||
@then('"{filename}" should not exist in the alpha sandbox for mps')
|
||||
def step_file_not_in_alpha(context: object, filename: str) -> None:
|
||||
alpha_info = context.mps_sandbox_infos[0]
|
||||
fpath = os.path.join(alpha_info.sandbox_path, filename)
|
||||
assert not os.path.isfile(fpath), f"{filename} still in alpha sandbox"
|
||||
|
||||
|
||||
@then('"{filename}" should still exist in the alpha sandbox for mps')
|
||||
def step_file_still_in_alpha(context: object, filename: str) -> None:
|
||||
alpha_info = context.mps_sandbox_infos[0]
|
||||
fpath = os.path.join(alpha_info.sandbox_path, filename)
|
||||
assert os.path.isfile(fpath), f"{filename} not found in alpha sandbox"
|
||||
|
||||
|
||||
@then("both projects should have the merged changes for mps")
|
||||
def step_both_merged(context: object) -> None:
|
||||
for name, path in context.mps_projects.items():
|
||||
content = Path(path, f"{name}.py").read_text()
|
||||
assert "fixed" in content, f"Project {name} not merged: {content}"
|
||||
|
||||
|
||||
@then("alpha should have the merged changes for mps")
|
||||
def step_alpha_merged(context: object) -> None:
|
||||
path = context.mps_projects["alpha"]
|
||||
content = Path(path, "alpha.py").read_text()
|
||||
assert "fixed" in content, f"Alpha not merged: {content}"
|
||||
|
||||
|
||||
@given(
|
||||
'the file "{filename}" in the primary sandbox is overwritten with '
|
||||
'"{content}" for mps'
|
||||
)
|
||||
def step_overwrite_primary_file(context: object, filename: str, content: str) -> None:
|
||||
primary = context.mps_sandbox_infos[0]
|
||||
fpath = os.path.join(primary.sandbox_path, filename)
|
||||
Path(fpath).write_text(content + "\n")
|
||||
|
||||
|
||||
@then('"{filename}" in the alpha sandbox should contain "{text}" for mps')
|
||||
def step_alpha_file_contains(context: object, filename: str, text: str) -> None:
|
||||
alpha_info = context.mps_sandbox_infos[0]
|
||||
content = Path(alpha_info.sandbox_path, filename).read_text()
|
||||
assert text in content, (
|
||||
f"Expected '{text}' in alpha's {filename} but got: {content}"
|
||||
)
|
||||
|
||||
|
||||
@then('"{filename}" in the beta sandbox should not contain "{text}" for mps')
|
||||
def step_beta_file_not_contains(context: object, filename: str, text: str) -> None:
|
||||
beta_info = context.mps_sandbox_infos[1]
|
||||
fpath = os.path.join(beta_info.sandbox_path, filename)
|
||||
if not os.path.isfile(fpath):
|
||||
return # File doesn't exist in beta — that's fine
|
||||
content = Path(fpath).read_text()
|
||||
assert text not in content, (
|
||||
f"'{text}' should not be in beta's {filename} but found: {content}"
|
||||
)
|
||||
|
||||
|
||||
@then('"{filename}" should not exist in the beta sandbox for mps')
|
||||
def step_file_not_in_beta(context: object, filename: str) -> None:
|
||||
beta_info = context.mps_sandbox_infos[1]
|
||||
fpath = os.path.join(beta_info.sandbox_path, filename)
|
||||
assert not os.path.isfile(fpath), f"{filename} found in beta sandbox"
|
||||
|
||||
|
||||
@then('the console output should contain "Apply Summary" for mps')
|
||||
def step_console_has_apply_summary(context: object) -> None:
|
||||
output = context.mps_console_output
|
||||
assert "Apply Summary" in output, (
|
||||
f"Expected 'Apply Summary' in console output but got:\n{output[:500]}"
|
||||
)
|
||||
|
||||
|
||||
@then("beta should have the original content for mps")
|
||||
def step_beta_unchanged(context: object) -> None:
|
||||
path = context.mps_projects["beta"]
|
||||
content = Path(path, "beta.py").read_text()
|
||||
assert "original" in content or "main" in content, (
|
||||
f"Expected beta to be unchanged but got: {content}"
|
||||
)
|
||||
|
||||
|
||||
@then("_apply_sandbox_changes should return False for mps")
|
||||
def step_apply_returns_false(context: object) -> None:
|
||||
assert context.mps_apply_result is False, (
|
||||
f"Expected False but got {context.mps_apply_result}"
|
||||
)
|
||||
@@ -243,7 +243,7 @@ def step_mocked_lifecycle_service_for_coverage(context) -> None:
|
||||
# Mock _create_sandbox_for_plan for execute tests
|
||||
create_sandbox_patcher = patch(
|
||||
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||
return_value=(None, None),
|
||||
return_value=(None, []),
|
||||
)
|
||||
create_sandbox_patcher.start()
|
||||
context._cleanup_handlers.append(create_sandbox_patcher.stop)
|
||||
|
||||
@@ -669,7 +669,7 @@ def step_invoke_execute_plan_queued(context):
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||
return_value=(None, None),
|
||||
return_value=(None, []),
|
||||
),
|
||||
patch(
|
||||
"cleveragents.application.services.plan_executor.PlanExecutor",
|
||||
@@ -723,7 +723,7 @@ def step_invoke_execute_plan_auto_progressed(context):
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||
return_value=(None, None),
|
||||
return_value=(None, []),
|
||||
),
|
||||
patch(
|
||||
"cleveragents.application.services.plan_executor.PlanExecutor",
|
||||
|
||||
@@ -177,7 +177,7 @@ def step_invoke_cli_execute(context: Context) -> None:
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||
return_value=(None, None),
|
||||
return_value=(None, []),
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._get_plan_executor",
|
||||
@@ -368,7 +368,7 @@ def step_invoke_cli_execute_no_plan_id(context: Context) -> None:
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||
return_value=(None, None),
|
||||
return_value=(None, []),
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._get_plan_executor",
|
||||
|
||||
@@ -130,6 +130,66 @@ class DecompositionResult:
|
||||
metrics: dict[str, int | float] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionCorrectionResult:
|
||||
"""Result of selective subtree recomputation for decision correction.
|
||||
|
||||
Captures the outcome of :meth:`DecompositionService.recompute_subtree`,
|
||||
separating the nodes that were re-evaluated from those that were left
|
||||
intact. This enables callers to audit exactly which parts of the
|
||||
decomposition tree changed and which were preserved.
|
||||
|
||||
Attributes:
|
||||
target_node_id: The ``node_id`` of the root node whose subtree was
|
||||
recomputed. All descendants of this node (inclusive) appear in
|
||||
``recomputed_nodes``; all other nodes appear in
|
||||
``preserved_nodes``.
|
||||
recomputed_nodes: Nodes that belong to the recomputed subtree
|
||||
(the target node and all its descendants, identified via BFS
|
||||
over ``children_ids``).
|
||||
preserved_nodes: Nodes outside the recomputed subtree that were
|
||||
left unchanged. Includes sibling branches and all ancestor
|
||||
nodes of the target.
|
||||
config: The :class:`DecompositionConfig` used for this correction
|
||||
pass. Defaults to :class:`DecompositionConfig` with no
|
||||
arguments when the caller does not supply one.
|
||||
metrics: Diagnostic counters produced during recomputation.
|
||||
Always contains the following keys:
|
||||
|
||||
- ``recomputed_count`` -- number of nodes in the recomputed
|
||||
subtree.
|
||||
- ``preserved_count`` -- number of nodes outside the subtree.
|
||||
- ``subtree_size`` -- total nodes visited during the BFS
|
||||
(equal to ``recomputed_count``).
|
||||
|
||||
Usage::
|
||||
|
||||
result = svc.recompute_subtree(
|
||||
node_id="middle_a",
|
||||
existing_result=decomp_result,
|
||||
)
|
||||
print(result.recomputed_node_ids) # ["middle_a", "leaf_a1", ...]
|
||||
print(result.preserved_node_ids) # ["root", "middle_b", ...]
|
||||
print(result.metrics["recomputed_count"])
|
||||
"""
|
||||
|
||||
target_node_id: str
|
||||
recomputed_nodes: list[DecompositionNode]
|
||||
preserved_nodes: list[DecompositionNode]
|
||||
config: DecompositionConfig
|
||||
metrics: dict[str, int | float] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def recomputed_node_ids(self) -> list[str]:
|
||||
"""Return the IDs of all recomputed nodes."""
|
||||
return [n.node_id for n in self.recomputed_nodes]
|
||||
|
||||
@property
|
||||
def preserved_node_ids(self) -> list[str]:
|
||||
"""Return the IDs of all preserved nodes."""
|
||||
return [n.node_id for n in self.preserved_nodes]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency graph
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -206,6 +266,7 @@ class DependencyGraph(BaseModel):
|
||||
|
||||
__all__: list[str] = [
|
||||
"ClusterStrategy",
|
||||
"DecisionCorrectionResult",
|
||||
"DecompositionConfig",
|
||||
"DecompositionNode",
|
||||
"DecompositionResult",
|
||||
|
||||
@@ -32,6 +32,7 @@ from cleveragents.application.services.decomposition_graph import (
|
||||
)
|
||||
from cleveragents.application.services.decomposition_models import (
|
||||
ClusterStrategy,
|
||||
DecisionCorrectionResult,
|
||||
DecompositionConfig,
|
||||
DecompositionNode,
|
||||
DecompositionResult,
|
||||
@@ -392,6 +393,77 @@ class DecompositionService:
|
||||
"""
|
||||
return self._closure_computer.compute_closure(graph, root_files, cutoff)
|
||||
|
||||
def recompute_subtree(
|
||||
self,
|
||||
node_id: str,
|
||||
existing_result: DecompositionResult,
|
||||
config: DecompositionConfig | None = None,
|
||||
) -> DecisionCorrectionResult:
|
||||
"""Recompute only the subtree rooted at *node_id*.
|
||||
|
||||
Identifies the subtree via breadth-first search over
|
||||
:attr:`DecompositionNode.children_ids` and partitions the nodes
|
||||
from *existing_result* into two groups: those inside the subtree
|
||||
(recomputed) and those outside it (preserved). Sibling branches
|
||||
and ancestor nodes are always preserved.
|
||||
|
||||
Args:
|
||||
node_id: The ``node_id`` of the root of the subtree to
|
||||
recompute. Must exist in *existing_result*.
|
||||
existing_result: The current :class:`DecompositionResult`
|
||||
whose nodes will be partitioned.
|
||||
config: Optional :class:`DecompositionConfig` to attach to
|
||||
the result. When ``None``, a default
|
||||
:class:`DecompositionConfig` is used.
|
||||
|
||||
Returns:
|
||||
A :class:`DecisionCorrectionResult` containing:
|
||||
|
||||
- ``recomputed_nodes`` -- nodes in the subtree rooted at
|
||||
*node_id* (inclusive).
|
||||
- ``preserved_nodes`` -- all other nodes from
|
||||
*existing_result*.
|
||||
- ``metrics`` -- diagnostic counters (``recomputed_count``,
|
||||
``preserved_count``, ``subtree_size``).
|
||||
|
||||
Raises:
|
||||
ValueError: If *node_id* is not found in *existing_result*.
|
||||
"""
|
||||
cfg = config or DecompositionConfig()
|
||||
node_map: dict[str, DecompositionNode] = {
|
||||
n.node_id: n for n in existing_result.nodes
|
||||
}
|
||||
if node_id not in node_map:
|
||||
raise ValueError(f"Node '{node_id}' not found in decomposition result.")
|
||||
subtree_ids: set[str] = set()
|
||||
queue: list[str] = [node_id]
|
||||
while queue:
|
||||
current_id = queue.pop(0)
|
||||
if current_id in subtree_ids:
|
||||
continue
|
||||
subtree_ids.add(current_id)
|
||||
current_node = node_map.get(current_id)
|
||||
if current_node is not None:
|
||||
queue.extend(current_node.children_ids)
|
||||
recomputed_nodes = [
|
||||
n for n in existing_result.nodes if n.node_id in subtree_ids
|
||||
]
|
||||
preserved_nodes = [
|
||||
n for n in existing_result.nodes if n.node_id not in subtree_ids
|
||||
]
|
||||
metrics: dict[str, int | float] = {
|
||||
"recomputed_count": len(recomputed_nodes),
|
||||
"preserved_count": len(preserved_nodes),
|
||||
"subtree_size": len(subtree_ids),
|
||||
}
|
||||
return DecisionCorrectionResult(
|
||||
target_node_id=node_id,
|
||||
recomputed_nodes=recomputed_nodes,
|
||||
preserved_nodes=preserved_nodes,
|
||||
config=cfg,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
|
||||
__all__: list[str] = [
|
||||
"DecompositionService",
|
||||
|
||||
@@ -1420,33 +1420,57 @@ def _cleanup_sandbox_for_plan(
|
||||
):
|
||||
continue
|
||||
|
||||
if GitWorktreeSandbox.cleanup_stale(resource.location, plan_id):
|
||||
return # Cleaned up — done
|
||||
GitWorktreeSandbox.cleanup_stale(resource.location, plan_id)
|
||||
|
||||
|
||||
class _SandboxInfo:
|
||||
"""Metadata for a per-resource sandbox."""
|
||||
|
||||
__slots__ = ("project_name", "resource_location", "sandbox_obj", "sandbox_path")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sandbox_path: str,
|
||||
sandbox_obj: Any,
|
||||
resource_location: str,
|
||||
project_name: str,
|
||||
) -> None:
|
||||
self.sandbox_path = sandbox_path
|
||||
self.sandbox_obj = sandbox_obj
|
||||
self.resource_location = resource_location
|
||||
self.project_name = project_name
|
||||
|
||||
|
||||
def _create_sandbox_for_plan(
|
||||
plan_id: str,
|
||||
service: PlanLifecycleService,
|
||||
) -> tuple[str | None, Any]:
|
||||
"""Create a git worktree sandbox for a plan's linked project.
|
||||
) -> tuple[str | None, list[_SandboxInfo]]:
|
||||
"""Create per-resource git worktree sandboxes for a plan.
|
||||
|
||||
Per spec §19310, each resource gets its own sandbox. A parent
|
||||
directory is created under ``.cleveragents/sandbox/<plan_id>/``
|
||||
with per-resource subdirectories named by resource ID.
|
||||
|
||||
Returns:
|
||||
A ``(sandbox_root, sandbox_object)`` tuple. When the plan's
|
||||
project has a git-checkout resource, *sandbox_object* is a
|
||||
:class:`GitWorktreeSandbox` and *sandbox_root* is the worktree
|
||||
path. Otherwise falls back to a flat directory under
|
||||
``.cleveragents/sandbox/`` and *sandbox_object* is ``None``.
|
||||
A ``(parent_sandbox_root, sandbox_infos)`` tuple.
|
||||
*parent_sandbox_root* is the parent directory containing all
|
||||
per-resource subdirectories (passed to ``PlanExecutor`` as
|
||||
``sandbox_root``). *sandbox_infos* is a list of
|
||||
:class:`_SandboxInfo` objects, one per git-checkout resource.
|
||||
When no git resources are found, falls back to a flat directory
|
||||
and returns an empty list.
|
||||
"""
|
||||
from cleveragents.application.container import get_container
|
||||
from cleveragents.infrastructure.sandbox.git_worktree import (
|
||||
GitWorktreeSandbox,
|
||||
)
|
||||
|
||||
container = get_container()
|
||||
plan = service.get_plan(plan_id)
|
||||
project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]
|
||||
|
||||
# Try to find a git-checkout resource for the first linked project
|
||||
sandboxes: list[_SandboxInfo] = []
|
||||
# Track processed repo paths to avoid cleanup_stale destroying
|
||||
# a sandbox we just created for the same repo (M1 fix).
|
||||
processed_repos: set[str] = set()
|
||||
|
||||
for project_name in project_names:
|
||||
try:
|
||||
project = container.namespaced_project_repo().get(project_name)
|
||||
@@ -1454,10 +1478,10 @@ def _create_sandbox_for_plan(
|
||||
continue
|
||||
if project is None:
|
||||
continue
|
||||
for lr in getattr(project, "linked_resources", []):
|
||||
for linked_resource in getattr(project, "linked_resources", []):
|
||||
try:
|
||||
resource = container.resource_registry_service().show_resource(
|
||||
lr.resource_id,
|
||||
linked_resource.resource_id,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
@@ -1466,6 +1490,11 @@ def _create_sandbox_for_plan(
|
||||
and resource.location
|
||||
and os.path.isdir(os.path.join(resource.location, ".git"))
|
||||
):
|
||||
repo_abs = os.path.realpath(resource.location)
|
||||
if repo_abs in processed_repos:
|
||||
continue # M1: skip duplicate repos
|
||||
processed_repos.add(repo_abs)
|
||||
|
||||
GitWorktreeSandbox.cleanup_stale(
|
||||
resource.location,
|
||||
plan_id,
|
||||
@@ -1474,13 +1503,34 @@ def _create_sandbox_for_plan(
|
||||
resource_id=resource.resource_id,
|
||||
original_path=resource.location,
|
||||
)
|
||||
ctx = sandbox.create(plan_id)
|
||||
return ctx.sandbox_path, sandbox
|
||||
try:
|
||||
ctx = sandbox.create(plan_id)
|
||||
except Exception:
|
||||
# M3: cleanup already-created sandboxes on failure
|
||||
for prev in sandboxes:
|
||||
prev.sandbox_obj.cleanup()
|
||||
raise
|
||||
sandboxes.append(
|
||||
_SandboxInfo(
|
||||
sandbox_path=ctx.sandbox_path,
|
||||
sandbox_obj=sandbox,
|
||||
resource_location=resource.location,
|
||||
project_name=project_name,
|
||||
)
|
||||
)
|
||||
|
||||
if sandboxes:
|
||||
# Always use the first resource's worktree as sandbox_root
|
||||
# (backward compatible — PlanExecutor and LLMExecuteActor
|
||||
# write all FILE: blocks here). For multi-resource plans,
|
||||
# _route_sandbox_files_to_worktrees() redistributes files
|
||||
# to the correct worktrees after execute completes.
|
||||
return sandboxes[0].sandbox_path, sandboxes
|
||||
|
||||
# Fallback: flat directory sandbox
|
||||
flat_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
|
||||
os.makedirs(flat_root, exist_ok=True)
|
||||
return flat_root, None
|
||||
return flat_root, []
|
||||
|
||||
|
||||
def _apply_sandbox_changes(
|
||||
@@ -1500,7 +1550,7 @@ def _apply_sandbox_changes(
|
||||
``True`` if changes were applied successfully, ``False`` if
|
||||
the merge failed (conflict, timeout, etc.).
|
||||
|
||||
Spec reference: ``specification.md`` §13241-13276.
|
||||
Spec reference: ``specification.md`` §19310-19313, §13241-13276.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
@@ -1511,7 +1561,11 @@ def _apply_sandbox_changes(
|
||||
project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]
|
||||
branch_name = f"cleveragents/plan-{plan_id}"
|
||||
|
||||
# Try git worktree merge for each linked git-checkout resource
|
||||
# Try git worktree merge for each linked git-checkout resource.
|
||||
# Per spec §19312: Apply commits each sandbox separately.
|
||||
merged_count = 0
|
||||
merge_failed = False
|
||||
|
||||
for project_name in project_names:
|
||||
try:
|
||||
project = container.namespaced_project_repo().get(project_name)
|
||||
@@ -1565,7 +1619,6 @@ def _apply_sandbox_changes(
|
||||
|
||||
# Count changed files from diff --stat
|
||||
stat_lines = (diff_stat.stdout or "").strip().splitlines()
|
||||
# Last line is summary; file lines above it
|
||||
artifact_count = max(0, len(stat_lines) - 1) if stat_lines else 0
|
||||
|
||||
# Parse insertions/deletions from --shortstat
|
||||
@@ -1600,18 +1653,16 @@ def _apply_sandbox_changes(
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
console.print(
|
||||
"[red]Merge timed out.[/red]\n"
|
||||
"[yellow]Run 'git merge --abort' manually "
|
||||
"to clean up the repository.[/yellow]"
|
||||
f"[red]Merge timed out for {project_name}.[/red]\n"
|
||||
"[yellow]Run 'git merge --abort' manually.[/yellow]"
|
||||
)
|
||||
return False
|
||||
merge_failed = True
|
||||
continue
|
||||
except subprocess.CalledProcessError as merge_err:
|
||||
# Git writes conflict info to stdout, not stderr
|
||||
detail = (merge_err.stdout or merge_err.stderr or "").strip()
|
||||
if not detail:
|
||||
detail = "Unknown merge error"
|
||||
console.print(f"[red]Merge failed:[/red] {detail}")
|
||||
# Abort the merge to leave the repo in a clean state
|
||||
console.print(f"[red]Merge failed for {project_name}:[/red] {detail}")
|
||||
try:
|
||||
abort_result = subprocess.run(
|
||||
["git", "merge", "--abort"],
|
||||
@@ -1623,15 +1674,14 @@ def _apply_sandbox_changes(
|
||||
except subprocess.TimeoutExpired:
|
||||
console.print(
|
||||
"[red]Merge abort timed out.[/red]\n"
|
||||
"[yellow]Run 'git merge --abort' manually "
|
||||
"to clean up the repository.[/yellow]"
|
||||
"[yellow]Run 'git merge --abort' manually.[/yellow]"
|
||||
)
|
||||
return False
|
||||
merge_failed = True
|
||||
continue
|
||||
if abort_result.returncode == 0:
|
||||
console.print(
|
||||
"[yellow]Merge aborted — project is unchanged. "
|
||||
"Resolve conflicts manually or re-run "
|
||||
"the plan.[/yellow]"
|
||||
f"[yellow]{project_name}: merge aborted — "
|
||||
"project is unchanged.[/yellow]"
|
||||
)
|
||||
else:
|
||||
abort_err = (
|
||||
@@ -1641,14 +1691,15 @@ def _apply_sandbox_changes(
|
||||
abort_err = "Unknown error"
|
||||
console.print(
|
||||
f"[red]Merge abort failed:[/red] {abort_err}\n"
|
||||
"[yellow]Run 'git merge --abort' manually "
|
||||
"to clean up the repository.[/yellow]"
|
||||
"[yellow]Run 'git merge --abort' manually.[/yellow]"
|
||||
)
|
||||
return False
|
||||
merge_failed = True
|
||||
continue
|
||||
|
||||
merged_count += 1
|
||||
applied_at = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# ── Apply Summary panel (spec §13241-13247) ──
|
||||
# ── Per-resource Apply Summary panel (spec §13241) ──
|
||||
summary = (
|
||||
f"[cyan]Plan:[/cyan] {plan_id}\n"
|
||||
f"[blue]Artifacts:[/blue] {artifact_count} file(s) updated\n"
|
||||
@@ -1663,7 +1714,6 @@ def _apply_sandbox_changes(
|
||||
worktree_removed = False
|
||||
branch_deleted = False
|
||||
|
||||
# Find and remove the worktree directory
|
||||
wt_list = subprocess.run(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
cwd=repo_path,
|
||||
@@ -1678,7 +1728,13 @@ def _apply_sandbox_changes(
|
||||
if part.startswith("worktree "):
|
||||
wt_path = part.split("worktree ", 1)[1]
|
||||
subprocess.run(
|
||||
["git", "worktree", "remove", "--force", wt_path],
|
||||
[
|
||||
"git",
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
wt_path,
|
||||
],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
@@ -1686,7 +1742,6 @@ def _apply_sandbox_changes(
|
||||
)
|
||||
worktree_removed = True
|
||||
|
||||
# Delete the branch
|
||||
del_result = subprocess.run(
|
||||
["git", "branch", "-D", branch_name],
|
||||
cwd=repo_path,
|
||||
@@ -1712,18 +1767,25 @@ def _apply_sandbox_changes(
|
||||
)
|
||||
console.print(Panel(cleanup_text, title="Sandbox Cleanup", expand=False))
|
||||
|
||||
# ── Next Steps panel (spec §13271-13274) ──
|
||||
console.print(
|
||||
Panel(
|
||||
"- Review git diff\n- Commit changes",
|
||||
title="Next Steps",
|
||||
expand=False,
|
||||
)
|
||||
# Show footer after all resources are processed
|
||||
if merged_count > 0:
|
||||
console.print(
|
||||
Panel(
|
||||
"- Review git diff\n- Commit changes",
|
||||
title="Next Steps",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
console.print("[green]✓ OK[/green] Changes applied")
|
||||
if merge_failed:
|
||||
console.print(
|
||||
"[yellow]Some resources failed to merge. See errors above.[/yellow]"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
# ── Footer (spec §13276) ──
|
||||
console.print("[green]✓ OK[/green] Changes applied")
|
||||
return True # Done — merged successfully
|
||||
if merge_failed:
|
||||
return False
|
||||
|
||||
# Fallback: flat file copy from .cleveragents/sandbox/
|
||||
sandbox_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
|
||||
@@ -1764,6 +1826,85 @@ def _apply_sandbox_changes(
|
||||
return failed_count == 0
|
||||
|
||||
|
||||
def _route_sandbox_files_to_worktrees(
|
||||
sandbox_infos: list[_SandboxInfo],
|
||||
) -> None:
|
||||
"""Route files from the primary sandbox to per-resource worktrees.
|
||||
|
||||
When a plan has multiple git-checkout resources, the LLM writes all
|
||||
``FILE:`` blocks to the first resource's worktree. This function
|
||||
moves files that belong to other resources into their respective
|
||||
worktrees by matching file paths against each resource's known
|
||||
file list (via ``git ls-files``).
|
||||
|
||||
Per spec §19310: each resource gets its own sandbox.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
if len(sandbox_infos) <= 1:
|
||||
return # Single resource — nothing to route
|
||||
|
||||
primary = sandbox_infos[0]
|
||||
|
||||
# Build file list for the primary resource so we never move
|
||||
# files that belong to it (C1 fix: prevents data loss when
|
||||
# projects share the same relative path, e.g. README.md).
|
||||
try:
|
||||
primary_result = subprocess.run(
|
||||
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
||||
cwd=primary.resource_location,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
primary_files: set[str] = {
|
||||
f.strip() for f in primary_result.stdout.splitlines() if f.strip()
|
||||
}
|
||||
except Exception:
|
||||
# Cannot determine primary file list — skip routing entirely
|
||||
# to avoid data loss (M-NEW-2: empty set would move all files).
|
||||
return
|
||||
|
||||
# Build file lists for non-primary resources
|
||||
resource_files: dict[int, set[str]] = {}
|
||||
for idx, info in enumerate(sandbox_infos[1:], start=1):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
||||
cwd=info.resource_location,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
resource_files[idx] = {
|
||||
f.strip() for f in result.stdout.splitlines() if f.strip()
|
||||
}
|
||||
except Exception:
|
||||
resource_files[idx] = set()
|
||||
|
||||
# Walk the primary sandbox and move files that belong elsewhere.
|
||||
# Only move a file if it matches a secondary resource's file list
|
||||
# AND does NOT exist in the primary resource's file list.
|
||||
for dirpath, _dirnames, filenames in os.walk(primary.sandbox_path):
|
||||
for fname in filenames:
|
||||
full_path = os.path.join(dirpath, fname)
|
||||
rel_path = os.path.relpath(full_path, primary.sandbox_path)
|
||||
|
||||
# Never move files that belong to the primary resource
|
||||
if rel_path in primary_files:
|
||||
continue
|
||||
|
||||
for idx, known_files in resource_files.items():
|
||||
if rel_path in known_files:
|
||||
target = sandbox_infos[idx]
|
||||
dst = os.path.join(target.sandbox_path, rel_path)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
shutil.move(full_path, dst)
|
||||
break
|
||||
|
||||
|
||||
def _commit_worktree_changes(worktree_path: str, plan_id: str) -> None:
|
||||
"""Stage and commit LLM output in the worktree branch.
|
||||
|
||||
@@ -2465,6 +2606,7 @@ def execute_plan(
|
||||
PreflightRejection,
|
||||
)
|
||||
|
||||
sandbox_infos: list[_SandboxInfo] = []
|
||||
try:
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
PlanPhase,
|
||||
@@ -2529,9 +2671,9 @@ def execute_plan(
|
||||
pre.execution_environment = execution_environment.lower()
|
||||
service._commit_plan(pre)
|
||||
|
||||
# Create sandbox for this plan (git worktree or flat fallback)
|
||||
# and build the executor with the sandbox path.
|
||||
sandbox_root, sandbox_obj = _create_sandbox_for_plan(plan_id, service)
|
||||
# Create per-resource sandboxes (spec §19310) and build the
|
||||
# executor with the sandbox path.
|
||||
sandbox_root, sandbox_infos = _create_sandbox_for_plan(plan_id, service)
|
||||
executor = _get_plan_executor(
|
||||
lifecycle_service=service,
|
||||
sandbox_root=sandbox_root,
|
||||
@@ -2605,13 +2747,11 @@ def execute_plan(
|
||||
executor.run_execute(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
|
||||
# Stage and commit LLM-generated files in the worktree
|
||||
# branch WITHOUT merging — the merge happens at apply time.
|
||||
if sandbox_obj is not None and sandbox_obj.context is not None:
|
||||
_commit_worktree_changes(
|
||||
sandbox_obj.context.sandbox_path,
|
||||
plan_id,
|
||||
)
|
||||
# Route files to correct per-resource worktrees (spec §19310)
|
||||
# then commit each worktree branch.
|
||||
_route_sandbox_files_to_worktrees(sandbox_infos)
|
||||
for sinfo in sandbox_infos:
|
||||
_commit_worktree_changes(sinfo.sandbox_path, plan_id)
|
||||
|
||||
# Notify A2A facade for protocol bookkeeping.
|
||||
# Use plan.status (read-only) instead of plan.execute (transition)
|
||||
@@ -2667,6 +2807,13 @@ def execute_plan(
|
||||
except Exception as e:
|
||||
console.print(f"[red]Unexpected error:[/red] {e}")
|
||||
raise typer.Abort() from e
|
||||
finally:
|
||||
# M4: cleanup sandboxes on any failure path.
|
||||
# GitWorktreeSandbox.cleanup() is idempotent — safe to call
|
||||
# even after a successful apply (which already cleaned up).
|
||||
for _sinfo in sandbox_infos:
|
||||
with contextlib.suppress(Exception):
|
||||
_sinfo.sandbox_obj.cleanup()
|
||||
|
||||
|
||||
@app.command("apply")
|
||||
|
||||
Reference in New Issue
Block a user