Merge pull request 'feat(plan): implement git worktree sandbox for execute and merge-based apply' (#5998) from feature/git-worktree-apply into master
CI / push-validation (push) Successful in 23s
CI / helm (push) Successful in 33s
CI / quality (push) Successful in 34s
CI / lint (push) Successful in 36s
CI / security (push) Successful in 51s
CI / build (push) Successful in 3m18s
CI / typecheck (push) Successful in 3m59s
CI / unit_tests (push) Successful in 4m59s
CI / e2e_tests (push) Successful in 6m13s
CI / docker (push) Successful in 1m20s
CI / integration_tests (push) Successful in 7m5s
CI / coverage (push) Successful in 10m32s
CI / status-check (push) Successful in 1s
CI / benchmark-regression (push) Has been skipped
CI / benchmark-publish (push) Has been cancelled
CI / push-validation (push) Successful in 23s
CI / helm (push) Successful in 33s
CI / quality (push) Successful in 34s
CI / lint (push) Successful in 36s
CI / security (push) Successful in 51s
CI / build (push) Successful in 3m18s
CI / typecheck (push) Successful in 3m59s
CI / unit_tests (push) Successful in 4m59s
CI / e2e_tests (push) Successful in 6m13s
CI / docker (push) Successful in 1m20s
CI / integration_tests (push) Successful in 7m5s
CI / coverage (push) Successful in 10m32s
CI / status-check (push) Successful in 1s
CI / benchmark-regression (push) Has been skipped
CI / benchmark-publish (push) Has been cancelled
Reviewed-on: #5998
This commit was merged in pull request #5998.
This commit is contained in:
@@ -7,6 +7,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
### Added
|
### 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
|
- **Automation Tracking System**: Replaced shared session state issue tracking with
|
||||||
individual per-agent tracking issues. Each agent now creates its own `[AUTO-<PREFIX>]`
|
individual per-agent tracking issues. Each agent now creates its own `[AUTO-<PREFIX>]`
|
||||||
titled issues with standardized headers, reporting intervals, and health indicators.
|
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 = patch(_PATCH_GET_LIFECYCLE, return_value=service)
|
||||||
p.start()
|
p.start()
|
||||||
context._r2boost_cleanups.append(p.stop)
|
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
|
context.r2boost_service = service
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,21 @@ def step_mocked_lifecycle_service_for_coverage(context) -> None:
|
|||||||
context._cleanup_handlers = []
|
context._cleanup_handlers = []
|
||||||
context._cleanup_handlers.append(patcher.stop)
|
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
|
# Widen the Rich console so table columns do not wrap during tests
|
||||||
console_patcher = patch.object(plan_module.console, "width", 200)
|
console_patcher = patch.object(plan_module.console, "width", 200)
|
||||||
console_patcher.start()
|
console_patcher.start()
|
||||||
|
|||||||
@@ -667,6 +667,10 @@ def step_invoke_execute_plan_queued(context):
|
|||||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||||
return_value=mock_service,
|
return_value=mock_service,
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||||
|
return_value=(None, None),
|
||||||
|
),
|
||||||
patch(
|
patch(
|
||||||
"cleveragents.application.services.plan_executor.PlanExecutor",
|
"cleveragents.application.services.plan_executor.PlanExecutor",
|
||||||
) as mock_executor_cls,
|
) as mock_executor_cls,
|
||||||
@@ -717,6 +721,10 @@ def step_invoke_execute_plan_auto_progressed(context):
|
|||||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||||
return_value=mock_service,
|
return_value=mock_service,
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||||
|
return_value=(None, None),
|
||||||
|
),
|
||||||
patch(
|
patch(
|
||||||
"cleveragents.application.services.plan_executor.PlanExecutor",
|
"cleveragents.application.services.plan_executor.PlanExecutor",
|
||||||
) as mock_executor_cls,
|
) 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_plan = _make_mock_lifecycle_plan(phase="apply", state="queued")
|
||||||
mock_service.apply_plan.return_value = mock_plan
|
mock_service.apply_plan.return_value = mock_plan
|
||||||
|
|
||||||
with patch(
|
with (
|
||||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
patch(
|
||||||
return_value=mock_service,
|
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||||
|
return_value=mock_service,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"cleveragents.cli.commands.plan._apply_sandbox_changes",
|
||||||
|
),
|
||||||
):
|
):
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
plan_app, ["apply", "--yes", "01JAAAAAAAAAAAAAAAAAAAAAAA"]
|
plan_app, ["apply", "--yes", "01JAAAAAAAAAAAAAAAAAAAAAAA"]
|
||||||
@@ -770,9 +783,14 @@ def step_invoke_apply_auto_select(context):
|
|||||||
state="queued",
|
state="queued",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with (
|
||||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
patch(
|
||||||
return_value=mock_service,
|
"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"])
|
result = runner.invoke(plan_app, ["apply", "--yes"])
|
||||||
context.result = result
|
context.result = result
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ def step_invoke_cli_execute(context: Context) -> None:
|
|||||||
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||||
return_value=context.mock_service_967,
|
return_value=context.mock_service_967,
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||||
|
return_value=(None, None),
|
||||||
|
),
|
||||||
patch(
|
patch(
|
||||||
"cleveragents.cli.commands.plan._get_plan_executor",
|
"cleveragents.cli.commands.plan._get_plan_executor",
|
||||||
return_value=context.mock_executor_967,
|
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",
|
"cleveragents.cli.commands.plan._get_lifecycle_service",
|
||||||
return_value=context.mock_service_967,
|
return_value=context.mock_service_967,
|
||||||
),
|
),
|
||||||
|
patch(
|
||||||
|
"cleveragents.cli.commands.plan._create_sandbox_for_plan",
|
||||||
|
return_value=(None, None),
|
||||||
|
),
|
||||||
patch(
|
patch(
|
||||||
"cleveragents.cli.commands.plan._get_plan_executor",
|
"cleveragents.cli.commands.plan._get_plan_executor",
|
||||||
return_value=context.mock_executor_967,
|
return_value=context.mock_executor_967,
|
||||||
|
|||||||
@@ -390,6 +390,12 @@ class A2aLocalFacade:
|
|||||||
return {"plan_id": plan_id, "status": "queued"}
|
return {"plan_id": plan_id, "status": "queued"}
|
||||||
if not plan_id:
|
if not plan_id:
|
||||||
raise ValueError("plan_id is required")
|
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)
|
plan = svc.execute_plan(plan_id)
|
||||||
return {"plan_id": plan.identity.plan_id, "status": plan.phase.value}
|
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,
|
token_count=len(content) // 4,
|
||||||
metadata={
|
metadata={
|
||||||
"path": rel_path,
|
"path": rel_path,
|
||||||
"detail_depth": 1,
|
"detail_depth": "1",
|
||||||
"relevance_score": 0.5,
|
"relevance_score": "0.5",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -362,10 +362,11 @@ class LLMExecuteActor:
|
|||||||
if self._context_assembler is not None:
|
if self._context_assembler is not None:
|
||||||
try:
|
try:
|
||||||
assembled_context = self._context_assembler.assemble(plan)
|
assembled_context = self._context_assembler.assemble(plan)
|
||||||
except Exception:
|
except Exception as _asm_err:
|
||||||
self._logger.warning(
|
self._logger.warning(
|
||||||
"execute_context_assembly_failed",
|
"execute_context_assembly_failed",
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
|
error=str(_asm_err),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build a prompt summarising the decisions
|
# Build a prompt summarising the decisions
|
||||||
|
|||||||
@@ -1345,7 +1345,330 @@ def _get_lifecycle_service():
|
|||||||
return container.plan_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.
|
"""Build a ``PlanExecutor`` wired with real LLM actors.
|
||||||
|
|
||||||
Resolves the ``ProviderRegistry`` and ``PlanLifecycleService`` from
|
Resolves the ``ProviderRegistry`` and ``PlanLifecycleService`` from
|
||||||
@@ -1396,6 +1719,7 @@ def _get_plan_executor(lifecycle_service: PlanLifecycleService | None = None) ->
|
|||||||
lifecycle_service=lifecycle_service,
|
lifecycle_service=lifecycle_service,
|
||||||
strategize_actor=strategize_actor,
|
strategize_actor=strategize_actor,
|
||||||
execute_actor=execute_actor,
|
execute_actor=execute_actor,
|
||||||
|
sandbox_root=sandbox_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1986,7 +2310,6 @@ def execute_plan(
|
|||||||
)
|
)
|
||||||
|
|
||||||
service = _get_lifecycle_service()
|
service = _get_lifecycle_service()
|
||||||
executor = _get_plan_executor(lifecycle_service=service)
|
|
||||||
|
|
||||||
# Track wall-clock start time for timing output
|
# Track wall-clock start time for timing output
|
||||||
execute_wall_start = datetime.now()
|
execute_wall_start = datetime.now()
|
||||||
@@ -2044,6 +2367,14 @@ def execute_plan(
|
|||||||
pre.execution_environment = execution_environment.lower()
|
pre.execution_environment = execution_environment.lower()
|
||||||
service._commit_plan(pre)
|
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
|
# Determine current phase and run the appropriate processing
|
||||||
current_plan = service.get_plan(plan_id)
|
current_plan = service.get_plan(plan_id)
|
||||||
if current_plan is None:
|
if current_plan is None:
|
||||||
@@ -2103,11 +2434,22 @@ def execute_plan(
|
|||||||
and current_plan.phase == PlanPhase.EXECUTE
|
and current_plan.phase == PlanPhase.EXECUTE
|
||||||
and current_plan.state == ProcessingState.QUEUED
|
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)
|
executor.run_execute(plan_id)
|
||||||
plan = service.get_plan(plan_id)
|
plan = service.get_plan(plan_id)
|
||||||
|
|
||||||
# Notify A2A facade for protocol bookkeeping
|
# Stage and commit LLM-generated files in the worktree
|
||||||
_notify_facade("plan.execute", {"plan_id": plan_id})
|
# 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:
|
if fmt != OutputFormat.RICH.value:
|
||||||
execute_elapsed_ms = int(
|
execute_elapsed_ms = int(
|
||||||
@@ -2259,60 +2601,10 @@ def lifecycle_apply_plan(
|
|||||||
# Normal path: plan is in Execute/complete → transition
|
# Normal path: plan is in Execute/complete → transition
|
||||||
plan = service.apply_plan(plan_id)
|
plan = service.apply_plan(plan_id)
|
||||||
|
|
||||||
# Apply changeset files from sandbox to project directory.
|
# Apply changeset: merge the git worktree branch back into
|
||||||
sandbox_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox")
|
# the project, or fall back to flat file copy for non-git
|
||||||
project_root = os.getcwd()
|
# projects.
|
||||||
_SKIP_APPLY = frozenset({".cleveragents", ".git", ".hg", ".svn"})
|
_apply_sandbox_changes(plan_id, service, console)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Complete the apply phase to terminal state.
|
# Complete the apply phase to terminal state.
|
||||||
plan = service.get_plan(plan_id)
|
plan = service.get_plan(plan_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user