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

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:
2026-04-09 14:04:14 +00:00
parent 33d5c0b244
commit 06428a5db6
11 changed files with 648 additions and 67 deletions
+12
View File
@@ -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.
+42
View File
@@ -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
+183
View File
@@ -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,
+6
View File
@@ -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
+350 -58
View File
@@ -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)