From 06428a5db6356e669fd92e918975c8e64de007b6 Mon Sep 17 00:00:00 2001 From: Hamza Khyari Date: Thu, 9 Apr 2026 14:04:14 +0000 Subject: [PATCH] feat(plan): implement git worktree sandbox for execute and merge-based apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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- — 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 --- CHANGELOG.md | 12 + features/git_worktree_apply.feature | 42 ++ features/steps/git_worktree_apply_steps.py | 183 ++++++++ .../steps/plan_cli_coverage_boost_r2_steps.py | 4 + .../steps/plan_cli_coverage_boost_steps.py | 15 + .../plan_lifecycle_commands_coverage_steps.py | 30 +- ...tdd_plan_execute_phase_processing_steps.py | 8 + src/cleveragents/a2a/facade.py | 6 + .../services/context_tier_hydrator.py | 4 +- .../application/services/llm_actors.py | 3 +- src/cleveragents/cli/commands/plan.py | 408 +++++++++++++++--- 11 files changed, 648 insertions(+), 67 deletions(-) create mode 100644 features/git_worktree_apply.feature create mode 100644 features/steps/git_worktree_apply_steps.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d0cfbbf..b26bc4871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-]` titled issues with standardized headers, reporting intervals, and health indicators. diff --git a/features/git_worktree_apply.feature b/features/git_worktree_apply.feature new file mode 100644 index 000000000..9beee0d8c --- /dev/null +++ b/features/git_worktree_apply.feature @@ -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 diff --git a/features/steps/git_worktree_apply_steps.py b/features/steps/git_worktree_apply_steps.py new file mode 100644 index 000000000..d60eb478f --- /dev/null +++ b/features/steps/git_worktree_apply_steps.py @@ -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")) diff --git a/features/steps/plan_cli_coverage_boost_r2_steps.py b/features/steps/plan_cli_coverage_boost_r2_steps.py index c762b2898..1e17954a1 100644 --- a/features/steps/plan_cli_coverage_boost_r2_steps.py +++ b/features/steps/plan_cli_coverage_boost_r2_steps.py @@ -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 diff --git a/features/steps/plan_cli_coverage_boost_steps.py b/features/steps/plan_cli_coverage_boost_steps.py index 6eb9f5c1c..2318c9584 100644 --- a/features/steps/plan_cli_coverage_boost_steps.py +++ b/features/steps/plan_cli_coverage_boost_steps.py @@ -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() diff --git a/features/steps/plan_lifecycle_commands_coverage_steps.py b/features/steps/plan_lifecycle_commands_coverage_steps.py index 7c201f99a..e43195e54 100644 --- a/features/steps/plan_lifecycle_commands_coverage_steps.py +++ b/features/steps/plan_lifecycle_commands_coverage_steps.py @@ -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 diff --git a/features/steps/tdd_plan_execute_phase_processing_steps.py b/features/steps/tdd_plan_execute_phase_processing_steps.py index 173afb6eb..2c7a92b78 100644 --- a/features/steps/tdd_plan_execute_phase_processing_steps.py +++ b/features/steps/tdd_plan_execute_phase_processing_steps.py @@ -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, diff --git a/src/cleveragents/a2a/facade.py b/src/cleveragents/a2a/facade.py index 472ec19ea..48f948f97 100644 --- a/src/cleveragents/a2a/facade.py +++ b/src/cleveragents/a2a/facade.py @@ -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} diff --git a/src/cleveragents/application/services/context_tier_hydrator.py b/src/cleveragents/application/services/context_tier_hydrator.py index 26fc1bac2..1a16919e5 100644 --- a/src/cleveragents/application/services/context_tier_hydrator.py +++ b/src/cleveragents/application/services/context_tier_hydrator.py @@ -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", }, ) diff --git a/src/cleveragents/application/services/llm_actors.py b/src/cleveragents/application/services/llm_actors.py index 0a16a07c4..ee5f15db9 100644 --- a/src/cleveragents/application/services/llm_actors.py +++ b/src/cleveragents/application/services/llm_actors.py @@ -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 diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index 7ffc875f2..d224fa39b 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -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-`` 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)