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

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:
2026-04-24 12:39:37 +00:00
parent 17b744c589
commit 32a649c5a5
10 changed files with 1206 additions and 66 deletions
@@ -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
+63
View File
@@ -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",
+208 -61
View File
@@ -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")