feat(plan): implement git worktree sandbox for execute and merge-based apply
CI / push-validation (pull_request) Successful in 16s
CI / helm (pull_request) Successful in 23s
CI / build (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 48s
CI / typecheck (pull_request) Successful in 53s
CI / security (pull_request) Successful in 53s
CI / quality (pull_request) Successful in 51s
CI / e2e_tests (pull_request) Successful in 3m27s
CI / integration_tests (pull_request) Successful in 7m14s
CI / unit_tests (pull_request) Successful in 8m34s
CI / docker (pull_request) Successful in 1m20s
CI / coverage (pull_request) Successful in 14m36s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 57m55s
CI / push-validation (pull_request) Successful in 16s
CI / helm (pull_request) Successful in 23s
CI / build (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 48s
CI / typecheck (pull_request) Successful in 53s
CI / security (pull_request) Successful in 53s
CI / quality (pull_request) Successful in 51s
CI / e2e_tests (pull_request) Successful in 3m27s
CI / integration_tests (pull_request) Successful in 7m14s
CI / unit_tests (pull_request) Successful in 8m34s
CI / docker (pull_request) Successful in 1m20s
CI / coverage (pull_request) Successful in 14m36s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 57m55s
Replace flat shutil.copy2 apply with spec-aligned git worktree flow
(specification.md §13225-13276):
Execute phase: creates an isolated git worktree via GitWorktreeSandbox
for the plan's linked git-checkout resource. LLM file output is
written to the worktree and committed on branch
cleveragents/plan-<plan_id> — no merge yet.
Apply phase: merges the worktree branch into the project's current
branch via git merge. Prints spec-aligned panels:
- Apply Summary: Plan ID, artifacts count, insertions/deletions,
project name, applied-at timestamp
- Sandbox Cleanup: worktree removed, branch merged to main
- Footer: ✓ OK Changes applied
Non-git projects (fs-directory resources) fall back to the original
flat directory sandbox with shutil.copy2.
Also fixes:
- context_tier_hydrator metadata types (int/float → string) that
caused Pydantic validation errors during context assembly
- A2A facade duplicate execute dispatch (idempotent handler)
- Logs actual error from context assembly failures
ISSUES CLOSED: #4454
This commit is contained in:
@@ -7,6 +7,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Added
|
||||
|
||||
- **Git Worktree Sandbox Apply** (#4454): The `plan apply` command now merges
|
||||
LLM-generated changes via `git merge` from an isolated worktree branch
|
||||
instead of flat `shutil.copy2`. Displays spec-aligned Apply Summary
|
||||
(plan ID, artifacts, insertions/deletions, project, timestamp), Sandbox
|
||||
Cleanup panel, and `✓ OK Changes applied` footer. Non-git projects fall
|
||||
back to the original flat file copy.
|
||||
|
||||
- **Context Hydration Fix** (#4454): Fixed `ContextFragment` metadata types
|
||||
(`detail_depth` and `relevance_score` must be strings, not int/float) that
|
||||
caused Pydantic validation errors during context assembly, resulting in the
|
||||
LLM receiving zero file context.
|
||||
|
||||
- **Automation Tracking System**: Replaced shared session state issue tracking with
|
||||
individual per-agent tracking issues. Each agent now creates its own `[AUTO-<PREFIX>]`
|
||||
titled issues with standardized headers, reporting intervals, and health indicators.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
@git-worktree-apply
|
||||
Feature: Git worktree sandbox apply lifecycle (#4454)
|
||||
Verifies that the apply phase merges LLM output from a git worktree
|
||||
branch back into the project's main branch.
|
||||
|
||||
Scenario: Create worktree sandbox for git-checkout resource for wt_apply
|
||||
Given a temp git project with 1 file for wt_apply
|
||||
When I create a sandbox for the project for wt_apply
|
||||
Then the sandbox path should be a directory for wt_apply
|
||||
And the sandbox path should differ from the project path for wt_apply
|
||||
|
||||
Scenario: Worktree branch exists after sandbox creation for wt_apply
|
||||
Given a temp git project with 1 file for wt_apply
|
||||
When I create a sandbox for the project for wt_apply
|
||||
Then the branch "cleveragents/plan-01TEST00000000000000000099" should exist for wt_apply
|
||||
|
||||
Scenario: Files written to sandbox appear on worktree branch for wt_apply
|
||||
Given a temp git project with 1 file for wt_apply
|
||||
And I create a sandbox for the project for wt_apply
|
||||
When I write "fixed.py" to the sandbox for wt_apply
|
||||
And I commit the sandbox for wt_apply
|
||||
Then "fixed.py" should exist on branch "cleveragents/plan-01TEST00000000000000000099" for wt_apply
|
||||
|
||||
Scenario: Merge applies changes to main branch for wt_apply
|
||||
Given a temp git project with 1 file for wt_apply
|
||||
And I create a sandbox for the project for wt_apply
|
||||
And I write "fixed.py" to the sandbox for wt_apply
|
||||
And I commit the sandbox for wt_apply
|
||||
When I merge the sandbox branch into main for wt_apply
|
||||
Then "fixed.py" should exist in the project root for wt_apply
|
||||
|
||||
Scenario: Cleanup removes worktree and branch for wt_apply
|
||||
Given a temp git project with 1 file for wt_apply
|
||||
And I create a sandbox for the project for wt_apply
|
||||
When I cleanup the sandbox for wt_apply
|
||||
Then the sandbox path should not exist for wt_apply
|
||||
And the branch "cleveragents/plan-01TEST00000000000000000099" should not exist for wt_apply
|
||||
|
||||
Scenario: Fallback to flat copy for non-git directory for wt_apply
|
||||
Given a temp non-git directory with 1 file for wt_apply
|
||||
When I copy files from sandbox to project for wt_apply
|
||||
Then the project should have the copied file for wt_apply
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Steps for git_worktree_apply.feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
_PLAN_ID = "01TEST00000000000000000099"
|
||||
_BRANCH = f"cleveragents/plan-{_PLAN_ID}"
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ── Given ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@given("a temp git project with 1 file for wt_apply")
|
||||
def step_git_project(context: object) -> None:
|
||||
d = tempfile.mkdtemp(prefix="wt-apply-")
|
||||
context.add_cleanup(shutil.rmtree, d, True)
|
||||
_git(["init", "-q"], d)
|
||||
_git(["config", "user.name", "T"], d)
|
||||
_git(["config", "user.email", "t@t"], d)
|
||||
_git(["config", "commit.gpgsign", "false"], d)
|
||||
Path(d, "main.py").write_text("print('hello')\n")
|
||||
_git(["add", "."], d)
|
||||
_git(["commit", "-q", "-m", "init"], d)
|
||||
context.wt_apply_project = d
|
||||
context.wt_apply_sandbox = None
|
||||
context.wt_apply_sandbox_obj = None
|
||||
|
||||
|
||||
@given("a temp non-git directory with 1 file for wt_apply")
|
||||
def step_non_git_dir(context: object) -> None:
|
||||
d = tempfile.mkdtemp(prefix="wt-apply-flat-")
|
||||
context.add_cleanup(shutil.rmtree, d, True)
|
||||
sandbox = os.path.join(d, "sandbox")
|
||||
os.makedirs(sandbox)
|
||||
Path(sandbox, "result.txt").write_text("output\n")
|
||||
context.wt_apply_project = d
|
||||
context.wt_apply_sandbox_flat = sandbox
|
||||
|
||||
|
||||
# ── When ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@when("I create a sandbox for the project for wt_apply")
|
||||
@given("I create a sandbox for the project for wt_apply")
|
||||
def step_create_sandbox(context: object) -> None:
|
||||
from cleveragents.infrastructure.sandbox.git_worktree import (
|
||||
GitWorktreeSandbox,
|
||||
)
|
||||
|
||||
sandbox = GitWorktreeSandbox(
|
||||
resource_id="res-wt-apply",
|
||||
original_path=context.wt_apply_project,
|
||||
)
|
||||
ctx = sandbox.create(plan_id=_PLAN_ID)
|
||||
context.wt_apply_sandbox = ctx.sandbox_path
|
||||
context.wt_apply_sandbox_obj = sandbox
|
||||
context.add_cleanup(sandbox.cleanup)
|
||||
|
||||
|
||||
@when("I write {filename:S} to the sandbox for wt_apply")
|
||||
@given("I write {filename:S} to the sandbox for wt_apply")
|
||||
def step_write_file(context: object, filename: str) -> None:
|
||||
filename = filename.strip('"')
|
||||
Path(context.wt_apply_sandbox, filename).write_text("# fixed\n")
|
||||
|
||||
|
||||
@when("I commit the sandbox for wt_apply")
|
||||
@given("I commit the sandbox for wt_apply")
|
||||
def step_commit_sandbox(context: object) -> None:
|
||||
wt = context.wt_apply_sandbox
|
||||
_git(["add", "-A"], wt)
|
||||
_git(["commit", "-m", "test commit"], wt)
|
||||
|
||||
|
||||
@when("I merge the sandbox branch into main for wt_apply")
|
||||
def step_merge(context: object) -> None:
|
||||
repo = context.wt_apply_project
|
||||
_git(["merge", _BRANCH, "--no-edit", "-m", "merge test"], repo)
|
||||
|
||||
|
||||
@when("I cleanup the sandbox for wt_apply")
|
||||
def step_cleanup(context: object) -> None:
|
||||
context.wt_apply_sandbox_obj.cleanup()
|
||||
|
||||
|
||||
@when("I copy files from sandbox to project for wt_apply")
|
||||
def step_flat_copy(context: object) -> None:
|
||||
sandbox = context.wt_apply_sandbox_flat
|
||||
project = context.wt_apply_project
|
||||
for fname in os.listdir(sandbox):
|
||||
src = os.path.join(sandbox, fname)
|
||||
if os.path.isfile(src):
|
||||
shutil.copy2(src, os.path.join(project, fname))
|
||||
|
||||
|
||||
# ── Then ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@then("the sandbox path should be a directory for wt_apply")
|
||||
def step_sandbox_is_dir(context: object) -> None:
|
||||
assert os.path.isdir(context.wt_apply_sandbox)
|
||||
|
||||
|
||||
@then("the sandbox path should differ from the project path for wt_apply")
|
||||
def step_sandbox_differs(context: object) -> None:
|
||||
assert context.wt_apply_sandbox != context.wt_apply_project
|
||||
|
||||
|
||||
@then("the branch {branch_name:S} should exist for wt_apply")
|
||||
def step_branch_exists(context: object, branch_name: str) -> None:
|
||||
branch_name = branch_name.strip('"')
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", f"refs/heads/{branch_name}"],
|
||||
cwd=context.wt_apply_project,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
assert result.returncode == 0, f"Branch {branch_name} does not exist"
|
||||
|
||||
|
||||
@then("the branch {branch_name:S} should not exist for wt_apply")
|
||||
def step_branch_not_exists(context: object, branch_name: str) -> None:
|
||||
branch_name = branch_name.strip('"')
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", f"refs/heads/{branch_name}"],
|
||||
cwd=context.wt_apply_project,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
assert result.returncode != 0, f"Branch {branch_name} still exists"
|
||||
|
||||
|
||||
@then("{filename:S} should exist on branch {branch_name:S} for wt_apply")
|
||||
def step_file_on_branch(context: object, filename: str, branch_name: str) -> None:
|
||||
filename = filename.strip('"')
|
||||
branch_name = branch_name.strip('"')
|
||||
result = subprocess.run(
|
||||
["git", "ls-tree", "--name-only", branch_name],
|
||||
cwd=context.wt_apply_project,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=10,
|
||||
)
|
||||
assert filename in result.stdout, (
|
||||
f"{filename} not found on {branch_name}: {result.stdout}"
|
||||
)
|
||||
|
||||
|
||||
@then("{filename:S} should exist in the project root for wt_apply")
|
||||
def step_file_in_root(context: object, filename: str) -> None:
|
||||
filename = filename.strip('"')
|
||||
assert os.path.isfile(os.path.join(context.wt_apply_project, filename))
|
||||
|
||||
|
||||
@then("the sandbox path should not exist for wt_apply")
|
||||
def step_sandbox_gone(context: object) -> None:
|
||||
assert not os.path.exists(context.wt_apply_sandbox)
|
||||
|
||||
|
||||
@then("the project should have the copied file for wt_apply")
|
||||
def step_flat_copy_result(context: object) -> None:
|
||||
assert os.path.isfile(os.path.join(context.wt_apply_project, "result.txt"))
|
||||
@@ -193,6 +193,10 @@ def step_mock_apply_happy(context: Context) -> None:
|
||||
p = patch(_PATCH_GET_LIFECYCLE, return_value=service)
|
||||
p.start()
|
||||
context._r2boost_cleanups.append(p.stop)
|
||||
# Mock _apply_sandbox_changes to avoid hitting the DI container
|
||||
p2 = patch("cleveragents.cli.commands.plan._apply_sandbox_changes")
|
||||
p2.start()
|
||||
context._r2boost_cleanups.append(p2.stop)
|
||||
context.r2boost_service = service
|
||||
|
||||
|
||||
|
||||
@@ -233,6 +233,21 @@ def step_mocked_lifecycle_service_for_coverage(context) -> None:
|
||||
context._cleanup_handlers = []
|
||||
context._cleanup_handlers.append(patcher.stop)
|
||||
|
||||
# Mock _apply_sandbox_changes to avoid hitting the DI container
|
||||
sandbox_patcher = patch(
|
||||
"cleveragents.cli.commands.plan._apply_sandbox_changes",
|
||||
)
|
||||
sandbox_patcher.start()
|
||||
context._cleanup_handlers.append(sandbox_patcher.stop)
|
||||
|
||||
# Mock _create_sandbox_for_plan for execute tests
|
||||
create_sandbox_patcher = patch(
|
||||
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||
return_value=(None, None),
|
||||
)
|
||||
create_sandbox_patcher.start()
|
||||
context._cleanup_handlers.append(create_sandbox_patcher.stop)
|
||||
|
||||
# Widen the Rich console so table columns do not wrap during tests
|
||||
console_patcher = patch.object(plan_module.console, "width", 200)
|
||||
console_patcher.start()
|
||||
|
||||
@@ -667,6 +667,10 @@ def step_invoke_execute_plan_queued(context):
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=mock_service,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||
return_value=(None, None),
|
||||
),
|
||||
patch(
|
||||
"cleveragents.application.services.plan_executor.PlanExecutor",
|
||||
) as mock_executor_cls,
|
||||
@@ -717,6 +721,10 @@ def step_invoke_execute_plan_auto_progressed(context):
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=mock_service,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||
return_value=(None, None),
|
||||
),
|
||||
patch(
|
||||
"cleveragents.application.services.plan_executor.PlanExecutor",
|
||||
) as mock_executor_cls,
|
||||
@@ -748,9 +756,14 @@ def step_invoke_apply_with_id(context):
|
||||
mock_plan = _make_mock_lifecycle_plan(phase="apply", state="queued")
|
||||
mock_service.apply_plan.return_value = mock_plan
|
||||
|
||||
with patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=mock_service,
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=mock_service,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._apply_sandbox_changes",
|
||||
),
|
||||
):
|
||||
result = runner.invoke(
|
||||
plan_app, ["apply", "--yes", "01JAAAAAAAAAAAAAAAAAAAAAAA"]
|
||||
@@ -770,9 +783,14 @@ def step_invoke_apply_auto_select(context):
|
||||
state="queued",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=mock_service,
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=mock_service,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._apply_sandbox_changes",
|
||||
),
|
||||
):
|
||||
result = runner.invoke(plan_app, ["apply", "--yes"])
|
||||
context.result = result
|
||||
|
||||
@@ -175,6 +175,10 @@ def step_invoke_cli_execute(context: Context) -> None:
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=context.mock_service_967,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||
return_value=(None, None),
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._get_plan_executor",
|
||||
return_value=context.mock_executor_967,
|
||||
@@ -362,6 +366,10 @@ def step_invoke_cli_execute_no_plan_id(context: Context) -> None:
|
||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||
return_value=context.mock_service_967,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||
return_value=(None, None),
|
||||
),
|
||||
patch(
|
||||
"cleveragents.cli.commands.plan._get_plan_executor",
|
||||
return_value=context.mock_executor_967,
|
||||
|
||||
@@ -390,6 +390,12 @@ class A2aLocalFacade:
|
||||
return {"plan_id": plan_id, "status": "queued"}
|
||||
if not plan_id:
|
||||
raise ValueError("plan_id is required")
|
||||
# If the plan has already reached (or passed) the execute phase,
|
||||
# acknowledge idempotently instead of attempting a duplicate
|
||||
# transition that would raise InvalidPhaseTransitionError.
|
||||
plan = svc.get_plan(plan_id)
|
||||
if plan.phase.value in ("execute", "apply"):
|
||||
return {"plan_id": plan.identity.plan_id, "status": plan.phase.value}
|
||||
plan = svc.execute_plan(plan_id)
|
||||
return {"plan_id": plan.identity.plan_id, "status": plan.phase.value}
|
||||
|
||||
|
||||
@@ -140,8 +140,8 @@ def hydrate_tiers_from_project(
|
||||
token_count=len(content) // 4,
|
||||
metadata={
|
||||
"path": rel_path,
|
||||
"detail_depth": 1,
|
||||
"relevance_score": 0.5,
|
||||
"detail_depth": "1",
|
||||
"relevance_score": "0.5",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -362,10 +362,11 @@ class LLMExecuteActor:
|
||||
if self._context_assembler is not None:
|
||||
try:
|
||||
assembled_context = self._context_assembler.assemble(plan)
|
||||
except Exception:
|
||||
except Exception as _asm_err:
|
||||
self._logger.warning(
|
||||
"execute_context_assembly_failed",
|
||||
plan_id=plan_id,
|
||||
error=str(_asm_err),
|
||||
)
|
||||
|
||||
# Build a prompt summarising the decisions
|
||||
|
||||
@@ -1345,7 +1345,330 @@ def _get_lifecycle_service():
|
||||
return container.plan_lifecycle_service()
|
||||
|
||||
|
||||
def _get_plan_executor(lifecycle_service: PlanLifecycleService | None = None) -> Any:
|
||||
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.
|
||||
|
||||
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``.
|
||||
"""
|
||||
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
|
||||
for project_name in project_names:
|
||||
try:
|
||||
project = container.namespaced_project_repo().get(project_name)
|
||||
except Exception:
|
||||
continue
|
||||
if project is None:
|
||||
continue
|
||||
for lr in getattr(project, "linked_resources", []):
|
||||
try:
|
||||
resource = container.resource_registry_service().show_resource(
|
||||
lr.resource_id,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if (
|
||||
resource.resource_type_name in ("git-checkout", "git")
|
||||
and resource.location
|
||||
and os.path.isdir(os.path.join(resource.location, ".git"))
|
||||
):
|
||||
sandbox = GitWorktreeSandbox(
|
||||
resource_id=resource.resource_id,
|
||||
original_path=resource.location,
|
||||
)
|
||||
ctx = sandbox.create(plan_id)
|
||||
return ctx.sandbox_path, sandbox
|
||||
|
||||
# Fallback: flat directory sandbox
|
||||
flat_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
|
||||
os.makedirs(flat_root, exist_ok=True)
|
||||
return flat_root, None
|
||||
|
||||
|
||||
def _apply_sandbox_changes(
|
||||
plan_id: str,
|
||||
service: PlanLifecycleService,
|
||||
console: Console,
|
||||
) -> None:
|
||||
"""Apply sandbox changes to the project.
|
||||
|
||||
Tries git worktree merge first. If the plan's sandbox was a git
|
||||
worktree (branch ``cleveragents/plan-<plan_id>`` exists), merges
|
||||
the branch, prints spec-aligned summary panels, and cleans up.
|
||||
Otherwise falls back to flat file copy from
|
||||
``.cleveragents/sandbox/``.
|
||||
|
||||
Spec reference: ``specification.md`` §13241-13276.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from cleveragents.application.container import get_container
|
||||
|
||||
container = get_container()
|
||||
plan = service.get_plan(plan_id)
|
||||
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
|
||||
for project_name in project_names:
|
||||
try:
|
||||
project = container.namespaced_project_repo().get(project_name)
|
||||
except Exception:
|
||||
continue
|
||||
if project is None:
|
||||
continue
|
||||
for lr in getattr(project, "linked_resources", []):
|
||||
try:
|
||||
resource = container.resource_registry_service().show_resource(
|
||||
lr.resource_id,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if (
|
||||
resource.resource_type_name not in ("git-checkout", "git")
|
||||
or not resource.location
|
||||
):
|
||||
continue
|
||||
repo_path = resource.location
|
||||
|
||||
# Check if our worktree branch exists (exact ref match)
|
||||
check = subprocess.run(
|
||||
["git", "rev-parse", "--verify", f"refs/heads/{branch_name}"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
if check.returncode != 0:
|
||||
continue
|
||||
|
||||
# Get diff summary BEFORE merge for the Apply Summary panel
|
||||
diff_stat = subprocess.run(
|
||||
["git", "diff", "--stat", f"HEAD...{branch_name}"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
diff_shortstat = subprocess.run(
|
||||
["git", "diff", "--shortstat", f"HEAD...{branch_name}"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# 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
|
||||
short = (diff_shortstat.stdout or "").strip()
|
||||
insertions = 0
|
||||
deletions = 0
|
||||
for part in short.split(","):
|
||||
part = part.strip()
|
||||
if "insertion" in part:
|
||||
insertions = int(part.split()[0])
|
||||
elif "deletion" in part:
|
||||
deletions = int(part.split()[0])
|
||||
|
||||
# Merge the worktree branch
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"merge",
|
||||
branch_name,
|
||||
"--no-edit",
|
||||
"-m",
|
||||
f"cleveragents: apply plan {plan_id}",
|
||||
],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.CalledProcessError as merge_err:
|
||||
console.print(f"[red]Merge failed:[/red] {merge_err.stderr.strip()}")
|
||||
return
|
||||
|
||||
applied_at = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# ── Apply Summary panel (spec §13241-13247) ──
|
||||
summary = (
|
||||
f"[cyan]Plan:[/cyan] {plan_id}\n"
|
||||
f"[blue]Artifacts:[/blue] {artifact_count} file(s) updated\n"
|
||||
f"[yellow]Changes:[/yellow] "
|
||||
f"{insertions} insertion(s), {deletions} deletion(s)\n"
|
||||
f"[blue]Project:[/blue] {project_name}\n"
|
||||
f"[green]Applied At:[/green] {applied_at}"
|
||||
)
|
||||
console.print(Panel(summary, title="Apply Summary", expand=False))
|
||||
|
||||
# ── Sandbox Cleanup (spec §13256-13260) ──
|
||||
worktree_removed = False
|
||||
branch_deleted = False
|
||||
|
||||
# Find and remove the worktree directory
|
||||
wt_list = subprocess.run(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
for wt_line in wt_list.stdout.split("\n\n"):
|
||||
if f"branch refs/heads/{branch_name}" in wt_line:
|
||||
for part in wt_line.splitlines():
|
||||
if part.startswith("worktree "):
|
||||
wt_path = part.split("worktree ", 1)[1]
|
||||
subprocess.run(
|
||||
["git", "worktree", "remove", "--force", wt_path],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
worktree_removed = True
|
||||
|
||||
# Delete the branch
|
||||
del_result = subprocess.run(
|
||||
["git", "branch", "-D", branch_name],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
branch_deleted = del_result.returncode == 0
|
||||
|
||||
subprocess.run(
|
||||
["git", "worktree", "prune"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
cleanup_text = (
|
||||
f"[green]Worktree:[/green] "
|
||||
f"{'removed' if worktree_removed else 'already clean'}\n"
|
||||
f"[green]Branch:[/green] "
|
||||
f"{'merged to main' if branch_deleted else 'cleaned'}"
|
||||
)
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
# ── Footer (spec §13276) ──
|
||||
console.print("[green]✓ OK[/green] Changes applied")
|
||||
return # Done — merged successfully
|
||||
|
||||
# Fallback: flat file copy from .cleveragents/sandbox/
|
||||
sandbox_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
|
||||
project_root = os.getcwd()
|
||||
_skip_dirs = frozenset({".cleveragents", ".git", ".hg", ".svn"})
|
||||
|
||||
if not os.path.isdir(sandbox_root):
|
||||
return
|
||||
|
||||
applied_count = 0
|
||||
failed_count = 0
|
||||
for dirpath, dirnames, filenames in os.walk(sandbox_root):
|
||||
dirnames[:] = [d for d in dirnames if d not in _skip_dirs]
|
||||
for fname in filenames:
|
||||
src = os.path.join(dirpath, fname)
|
||||
rel = os.path.relpath(src, sandbox_root)
|
||||
dst = os.path.normpath(os.path.join(project_root, rel))
|
||||
if not dst.startswith(project_root + os.sep):
|
||||
continue
|
||||
if rel.split(os.sep)[0] in _skip_dirs:
|
||||
continue
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(src, dst)
|
||||
applied_count += 1
|
||||
except OSError:
|
||||
failed_count += 1
|
||||
|
||||
if applied_count > 0:
|
||||
console.print(
|
||||
f"[green]Applied {applied_count} file(s) from sandbox to project.[/green]"
|
||||
)
|
||||
if failed_count == 0 and applied_count > 0:
|
||||
shutil.rmtree(sandbox_root, ignore_errors=True)
|
||||
if applied_count > 0:
|
||||
console.print("[green]✓ OK[/green] Changes applied")
|
||||
|
||||
|
||||
def _commit_worktree_changes(worktree_path: str, plan_id: str) -> None:
|
||||
"""Stage and commit LLM output in the worktree branch.
|
||||
|
||||
Commits without merging — the merge happens during the apply phase.
|
||||
Silently ignores failures (e.g. no changes to commit).
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "add", "-A"],
|
||||
cwd=worktree_path,
|
||||
check=True,
|
||||
timeout=10,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
f"cleveragents: plan {plan_id} execute output",
|
||||
],
|
||||
cwd=worktree_path,
|
||||
check=True,
|
||||
timeout=10,
|
||||
capture_output=True,
|
||||
)
|
||||
except Exception:
|
||||
pass # No changes or commit failed; apply falls back to flat copy
|
||||
|
||||
|
||||
def _get_plan_executor(
|
||||
lifecycle_service: PlanLifecycleService | None = None,
|
||||
sandbox_root: str | None = None,
|
||||
) -> Any:
|
||||
"""Build a ``PlanExecutor`` wired with real LLM actors.
|
||||
|
||||
Resolves the ``ProviderRegistry`` and ``PlanLifecycleService`` from
|
||||
@@ -1396,6 +1719,7 @@ def _get_plan_executor(lifecycle_service: PlanLifecycleService | None = None) ->
|
||||
lifecycle_service=lifecycle_service,
|
||||
strategize_actor=strategize_actor,
|
||||
execute_actor=execute_actor,
|
||||
sandbox_root=sandbox_root,
|
||||
)
|
||||
|
||||
|
||||
@@ -1986,7 +2310,6 @@ def execute_plan(
|
||||
)
|
||||
|
||||
service = _get_lifecycle_service()
|
||||
executor = _get_plan_executor(lifecycle_service=service)
|
||||
|
||||
# Track wall-clock start time for timing output
|
||||
execute_wall_start = datetime.now()
|
||||
@@ -2044,6 +2367,14 @@ 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)
|
||||
executor = _get_plan_executor(
|
||||
lifecycle_service=service,
|
||||
sandbox_root=sandbox_root,
|
||||
)
|
||||
|
||||
# Determine current phase and run the appropriate processing
|
||||
current_plan = service.get_plan(plan_id)
|
||||
if current_plan is None:
|
||||
@@ -2103,11 +2434,22 @@ def execute_plan(
|
||||
and current_plan.phase == PlanPhase.EXECUTE
|
||||
and current_plan.state == ProcessingState.QUEUED
|
||||
):
|
||||
# Notify A2A facade BEFORE running execute so the facade
|
||||
# sees execute/queued, not execute/complete. Notifying
|
||||
# after run_execute causes an "Invalid phase transition
|
||||
# from execute to execute" error because the plan has
|
||||
# already completed.
|
||||
_notify_facade("plan.execute", {"plan_id": plan_id})
|
||||
executor.run_execute(plan_id)
|
||||
plan = service.get_plan(plan_id)
|
||||
|
||||
# Notify A2A facade for protocol bookkeeping
|
||||
_notify_facade("plan.execute", {"plan_id": 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,
|
||||
)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
execute_elapsed_ms = int(
|
||||
@@ -2259,60 +2601,10 @@ def lifecycle_apply_plan(
|
||||
# Normal path: plan is in Execute/complete → transition
|
||||
plan = service.apply_plan(plan_id)
|
||||
|
||||
# Apply changeset files from sandbox to project directory.
|
||||
sandbox_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
|
||||
project_root = os.getcwd()
|
||||
_SKIP_APPLY = frozenset({".cleveragents", ".git", ".hg", ".svn"})
|
||||
|
||||
if os.path.isdir(sandbox_root):
|
||||
applied_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
for dirpath, dirnames, filenames in os.walk(sandbox_root):
|
||||
dirnames[:] = [d for d in dirnames if d not in _SKIP_APPLY]
|
||||
for fname in filenames:
|
||||
src_path = os.path.join(dirpath, fname)
|
||||
rel_path = os.path.relpath(src_path, sandbox_root)
|
||||
|
||||
dst_path = os.path.normpath(os.path.join(project_root, rel_path))
|
||||
if not dst_path.startswith(project_root + os.sep):
|
||||
console.print(
|
||||
f"[yellow]Skipped unsafe path: {rel_path}[/yellow]"
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
first_part = rel_path.split(os.sep)[0]
|
||||
if first_part in _SKIP_APPLY:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(src_path, dst_path)
|
||||
applied_count += 1
|
||||
except OSError as copy_err:
|
||||
console.print(
|
||||
f"[red]Failed to apply {rel_path}: {copy_err}[/red]"
|
||||
)
|
||||
failed_count += 1
|
||||
|
||||
if applied_count > 0:
|
||||
console.print(
|
||||
f"[green]Applied {applied_count} file(s) "
|
||||
f"from sandbox to project.[/green]"
|
||||
)
|
||||
if skipped_count > 0:
|
||||
console.print(
|
||||
f"[dim]Skipped {skipped_count} protected/unsafe file(s).[/dim]"
|
||||
)
|
||||
if failed_count > 0:
|
||||
console.print(
|
||||
f"[red]{failed_count} file(s) failed to apply. "
|
||||
f"Sandbox preserved at {sandbox_root}[/red]"
|
||||
)
|
||||
elif applied_count > 0:
|
||||
shutil.rmtree(sandbox_root, ignore_errors=True)
|
||||
# Apply changeset: merge the git worktree branch back into
|
||||
# the project, or fall back to flat file copy for non-git
|
||||
# projects.
|
||||
_apply_sandbox_changes(plan_id, service, console)
|
||||
|
||||
# Complete the apply phase to terminal state.
|
||||
plan = service.get_plan(plan_id)
|
||||
|
||||
Reference in New Issue
Block a user