diff --git a/features/git_worktree_class_methods.feature b/features/git_worktree_class_methods.feature new file mode 100644 index 000000000..b7b7f4dd9 --- /dev/null +++ b/features/git_worktree_class_methods.feature @@ -0,0 +1,41 @@ +Feature: GitWorktreeSandbox class methods for stale cleanup and diff + As a developer + I want class-level helpers to clean up stale worktrees and generate diffs + So that the CLI can manage sandbox lifecycle without a sandbox instance + + Background: + Given a gwt_cm test git repository is initialised + + Scenario: cleanup_stale removes a stale worktree branch + Given a gwt_cm stale worktree branch exists for plan "plan-stale-001" + When I call GitWorktreeSandbox.cleanup_stale for plan "plan-stale-001" + Then the gwt_cm stale branch should no longer exist + + Scenario: cleanup_stale is idempotent when no stale branch exists + When I call GitWorktreeSandbox.cleanup_stale for plan "plan-nonexistent-999" + Then no gwt_cm exception should have been raised + + Scenario: cleanup_stale handles empty plan_id gracefully + When I call GitWorktreeSandbox.cleanup_stale with empty plan_id + Then no gwt_cm exception should have been raised + + Scenario: cleanup_stale handles empty original_path gracefully + When I call GitWorktreeSandbox.cleanup_stale with empty original_path + Then no gwt_cm exception should have been raised + + Scenario: diff_against_head returns None when no worktree branch exists + When I call GitWorktreeSandbox.diff_against_head for plan "plan-no-branch-001" + Then the gwt_cm diff result should be None + + Scenario: diff_against_head returns diff when worktree branch has changes + Given a gwt_cm worktree branch with changes exists for plan "plan-diff-001" + When I call GitWorktreeSandbox.diff_against_head for plan "plan-diff-001" + Then the gwt_cm diff result should not be None + + Scenario: diff_against_head handles empty plan_id gracefully + When I call GitWorktreeSandbox.diff_against_head with empty plan_id + Then the gwt_cm diff result should be None + + Scenario: diff_against_head handles empty original_path gracefully + When I call GitWorktreeSandbox.diff_against_head with empty original_path + Then the gwt_cm diff result should be None diff --git a/features/steps/git_worktree_class_methods_steps.py b/features/steps/git_worktree_class_methods_steps.py new file mode 100644 index 000000000..6703ccc02 --- /dev/null +++ b/features/steps/git_worktree_class_methods_steps.py @@ -0,0 +1,245 @@ +"""Step definitions for GitWorktreeSandbox class methods feature. + +All steps use the ``gwt_cm`` prefix to avoid collisions with other step files. + +Note: GitWorktreeSandbox is imported lazily inside step functions to ensure +the isolated repo's src directory (added by environment.py before_all) takes +precedence over PYTHONPATH entries. +""" + +from __future__ import annotations + +import os +import subprocess +import tempfile + +from behave import given, then, when +from behave.runner import Context + + +def _get_gwt() -> type: + """Lazily import GitWorktreeSandbox to use the isolated repo's version.""" + from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox + + return GitWorktreeSandbox + + +def _init_test_repo_cm(ctx: Context) -> str: + """Create a temporary git repo with an initial commit.""" + repo_dir = tempfile.mkdtemp(prefix="gwt-cm-test-repo-") + subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "commit.gpgSign", "false"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + readme = os.path.join(repo_dir, "README.md") + with open(readme, "w") as f: + f.write("# Test Repo\n") + subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "Initial commit"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + return repo_dir + + +@given("a gwt_cm test git repository is initialised") +def step_gwt_cm_init_repo(ctx: Context) -> None: + """Initialise a temporary git repository for testing.""" + ctx.gwt_cm_repo_dir = _init_test_repo_cm(ctx) + ctx.gwt_cm_exception: Exception | None = None + ctx.gwt_cm_diff_result: str | None = None + + +@given('a gwt_cm stale worktree branch exists for plan "{plan_id}"') +def step_gwt_cm_create_stale_branch(ctx: Context, plan_id: str) -> None: + """Create a stale worktree branch to simulate a previous execute.""" + repo_dir: str = ctx.gwt_cm_repo_dir + branch_name = f"cleveragents/plan-{plan_id}" + # Create the branch directly without a worktree + subprocess.run( + ["git", "branch", branch_name], + cwd=repo_dir, + capture_output=True, + check=True, + ) + + +@when('I call GitWorktreeSandbox.cleanup_stale for plan "{plan_id}"') +def step_gwt_cm_call_cleanup_stale(ctx: Context, plan_id: str) -> None: + """Call the cleanup_stale class method.""" + GitWorktreeSandbox = _get_gwt() + try: + GitWorktreeSandbox.cleanup_stale(ctx.gwt_cm_repo_dir, plan_id) + ctx.gwt_cm_exception = None + except Exception as exc: + ctx.gwt_cm_exception = exc + + +@when("I call GitWorktreeSandbox.cleanup_stale with empty plan_id") +def step_gwt_cm_cleanup_stale_empty_plan_id(ctx: Context) -> None: + """Call cleanup_stale with an empty plan_id.""" + GitWorktreeSandbox = _get_gwt() + try: + GitWorktreeSandbox.cleanup_stale(ctx.gwt_cm_repo_dir, "") + ctx.gwt_cm_exception = None + except Exception as exc: + ctx.gwt_cm_exception = exc + + +@when("I call GitWorktreeSandbox.cleanup_stale with empty original_path") +def step_gwt_cm_cleanup_stale_empty_path(ctx: Context) -> None: + """Call cleanup_stale with an empty original_path.""" + GitWorktreeSandbox = _get_gwt() + try: + GitWorktreeSandbox.cleanup_stale("", "some-plan-id") + ctx.gwt_cm_exception = None + except Exception as exc: + ctx.gwt_cm_exception = exc + + +@then("the gwt_cm stale branch should no longer exist") +def step_gwt_cm_branch_not_exist(ctx: Context) -> None: + """Assert that the stale branch has been removed.""" + result = subprocess.run( + ["git", "branch", "--list"], + cwd=ctx.gwt_cm_repo_dir, + capture_output=True, + text=True, + check=True, + ) + # No cleveragents/plan-* branches should remain + branches = result.stdout.strip() + assert "cleveragents/plan-" not in branches, ( + f"Expected no cleveragents/plan-* branches, but found: {branches}" + ) + + +@then("no gwt_cm exception should have been raised") +def step_gwt_cm_no_exception(ctx: Context) -> None: + """Assert that no exception was raised.""" + assert ctx.gwt_cm_exception is None, ( + f"Expected no exception, but got: {ctx.gwt_cm_exception}" + ) + + +@given('a gwt_cm worktree branch with changes exists for plan "{plan_id}"') +def step_gwt_cm_create_branch_with_changes(ctx: Context, plan_id: str) -> None: + """Create a branch with a committed change to simulate execute output.""" + repo_dir: str = ctx.gwt_cm_repo_dir + branch_name = f"cleveragents/plan-{plan_id}" + + # Create and switch to the new branch + subprocess.run( + ["git", "checkout", "-b", branch_name], + cwd=repo_dir, + capture_output=True, + check=True, + ) + + # Add a new file and commit it + new_file = os.path.join(repo_dir, "generated.py") + with open(new_file, "w") as f: + f.write("# Generated by plan\nresult = 42\n") + subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", f"Plan {plan_id} output"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + + # Switch back to the original branch + subprocess.run( + ["git", "checkout", "master"], + cwd=repo_dir, + capture_output=True, + check=False, + ) + # Try main if master doesn't exist + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=repo_dir, + capture_output=True, + text=True, + check=False, + ) + if result.stdout.strip() != "master": + subprocess.run( + ["git", "checkout", "main"], + cwd=repo_dir, + capture_output=True, + check=False, + ) + + +@when('I call GitWorktreeSandbox.diff_against_head for plan "{plan_id}"') +def step_gwt_cm_call_diff_against_head(ctx: Context, plan_id: str) -> None: + """Call the diff_against_head class method.""" + GitWorktreeSandbox = _get_gwt() + try: + ctx.gwt_cm_diff_result = GitWorktreeSandbox.diff_against_head( + ctx.gwt_cm_repo_dir, plan_id + ) + ctx.gwt_cm_exception = None + except Exception as exc: + ctx.gwt_cm_exception = exc + ctx.gwt_cm_diff_result = None + + +@when("I call GitWorktreeSandbox.diff_against_head with empty plan_id") +def step_gwt_cm_diff_empty_plan_id(ctx: Context) -> None: + """Call diff_against_head with an empty plan_id.""" + GitWorktreeSandbox = _get_gwt() + try: + ctx.gwt_cm_diff_result = GitWorktreeSandbox.diff_against_head( + ctx.gwt_cm_repo_dir, "" + ) + ctx.gwt_cm_exception = None + except Exception as exc: + ctx.gwt_cm_exception = exc + ctx.gwt_cm_diff_result = None + + +@when("I call GitWorktreeSandbox.diff_against_head with empty original_path") +def step_gwt_cm_diff_empty_path(ctx: Context) -> None: + """Call diff_against_head with an empty original_path.""" + GitWorktreeSandbox = _get_gwt() + try: + ctx.gwt_cm_diff_result = GitWorktreeSandbox.diff_against_head("", "some-plan") + ctx.gwt_cm_exception = None + except Exception as exc: + ctx.gwt_cm_exception = exc + ctx.gwt_cm_diff_result = None + + +@then("the gwt_cm diff result should be None") +def step_gwt_cm_diff_is_none(ctx: Context) -> None: + """Assert that the diff result is None.""" + assert ctx.gwt_cm_diff_result is None, ( + f"Expected diff result to be None, but got: {ctx.gwt_cm_diff_result!r}" + ) + + +@then("the gwt_cm diff result should not be None") +def step_gwt_cm_diff_is_not_none(ctx: Context) -> None: + """Assert that the diff result is not None.""" + assert ctx.gwt_cm_diff_result is not None, ( + "Expected diff result to not be None, but it was None" + ) diff --git a/features/steps/strategy_actor_resolution_steps.py b/features/steps/strategy_actor_resolution_steps.py new file mode 100644 index 000000000..caee62b4a --- /dev/null +++ b/features/steps/strategy_actor_resolution_steps.py @@ -0,0 +1,80 @@ +"""Step definitions for strategy actor resolution feature. + +All steps use the ``sar`` prefix to avoid collisions with other step files. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.application.services.strategy_actor import resolve_strategy_actor + + +@given("a sar mock provider registry is available") +def step_sar_mock_registry(ctx: Context) -> None: + """Set up a mock provider registry.""" + ctx.sar_registry: Any = MagicMock() + ctx.sar_registry.__bool__ = lambda self: True + + +@given("a sar mock lifecycle service is available") +def step_sar_mock_lifecycle(ctx: Context) -> None: + """Set up a mock lifecycle service.""" + ctx.sar_lifecycle: Any = MagicMock() + ctx.sar_lifecycle.get_plan = MagicMock(return_value=MagicMock()) + ctx.sar_lifecycle.get_action = MagicMock(return_value=MagicMock()) + + +@when('I call resolve_strategy_actor with config_value "{config_value}"') +def step_sar_call_with_config(ctx: Context, config_value: str) -> None: + """Call resolve_strategy_actor with the given config_value.""" + registry = getattr(ctx, "sar_registry", None) + lifecycle = getattr(ctx, "sar_lifecycle", MagicMock()) + ctx.sar_result = resolve_strategy_actor( + provider_registry=registry, + lifecycle_service=lifecycle, + config_value=config_value, + ) + + +@when("I call resolve_strategy_actor with no provider registry") +def step_sar_call_no_registry(ctx: Context) -> None: + """Call resolve_strategy_actor with no provider registry.""" + lifecycle = getattr(ctx, "sar_lifecycle", MagicMock()) + ctx.sar_result = resolve_strategy_actor( + provider_registry=None, + lifecycle_service=lifecycle, + config_value=None, + ) + + +@when("I call resolve_strategy_actor with no config_value") +def step_sar_call_no_config(ctx: Context) -> None: + """Call resolve_strategy_actor with no config_value.""" + registry = getattr(ctx, "sar_registry", None) + lifecycle = getattr(ctx, "sar_lifecycle", MagicMock()) + ctx.sar_result = resolve_strategy_actor( + provider_registry=registry, + lifecycle_service=lifecycle, + config_value=None, + ) + + +@then("the sar resolved actor should be None") +def step_sar_result_is_none(ctx: Context) -> None: + """Assert that the resolved actor is None.""" + assert ctx.sar_result is None, ( + f"Expected resolved actor to be None, but got: {ctx.sar_result!r}" + ) + + +@then("the sar resolved actor should not be None") +def step_sar_result_is_not_none(ctx: Context) -> None: + """Assert that the resolved actor is not None.""" + assert ctx.sar_result is not None, ( + "Expected resolved actor to not be None, but it was None" + ) diff --git a/features/strategy_actor_resolution.feature b/features/strategy_actor_resolution.feature new file mode 100644 index 000000000..858355cf1 --- /dev/null +++ b/features/strategy_actor_resolution.feature @@ -0,0 +1,27 @@ +Feature: Strategy actor resolution for plan execution + As a developer + I want resolve_strategy_actor to select the correct strategize actor + So that plan execution uses the right LLM or stub actor + + Scenario: resolve_strategy_actor returns None when config_value is "stub" + Given a sar mock provider registry is available + And a sar mock lifecycle service is available + When I call resolve_strategy_actor with config_value "stub" + Then the sar resolved actor should be None + + Scenario: resolve_strategy_actor returns None when provider_registry is None + Given a sar mock lifecycle service is available + When I call resolve_strategy_actor with no provider registry + Then the sar resolved actor should be None + + Scenario: resolve_strategy_actor returns LLMStrategizeActor when registry is available + Given a sar mock provider registry is available + And a sar mock lifecycle service is available + When I call resolve_strategy_actor with config_value "llm" + Then the sar resolved actor should not be None + + Scenario: resolve_strategy_actor returns LLMStrategizeActor when config_value is None + Given a sar mock provider registry is available + And a sar mock lifecycle service is available + When I call resolve_strategy_actor with no config_value + Then the sar resolved actor should not be None diff --git a/robot/git_worktree_class_methods.robot b/robot/git_worktree_class_methods.robot new file mode 100644 index 000000000..23e3c578c --- /dev/null +++ b/robot/git_worktree_class_methods.robot @@ -0,0 +1,76 @@ +*** Settings *** +Documentation Integration tests for GitWorktreeSandbox class methods: +... cleanup_stale and diff_against_head. +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_git_worktree_class_methods.py + +*** Test Cases *** +Cleanup Stale With No Existing Branch Is Idempotent + [Documentation] cleanup_stale does nothing when no stale branch exists + ${result}= Run Process ${PYTHON} ${HELPER} cleanup-stale-no-branch cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cleanup-stale-no-branch-ok + +Cleanup Stale Removes Existing Branch + [Documentation] cleanup_stale removes a stale worktree branch + ${result}= Run Process ${PYTHON} ${HELPER} cleanup-stale-removes-branch cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cleanup-stale-removes-branch-ok + +Cleanup Stale With Empty Plan ID Is Safe + [Documentation] cleanup_stale handles empty plan_id without raising + ${result}= Run Process ${PYTHON} ${HELPER} cleanup-stale-empty-plan-id cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} cleanup-stale-empty-plan-id-ok + +Diff Against Head Returns None When No Branch + [Documentation] diff_against_head returns None when no worktree branch exists + ${result}= Run Process ${PYTHON} ${HELPER} diff-no-branch cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} diff-no-branch-ok + +Diff Against Head Returns Diff When Branch Has Changes + [Documentation] diff_against_head returns a non-empty diff when the branch has commits + ${result}= Run Process ${PYTHON} ${HELPER} diff-with-changes cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} diff-with-changes-ok + +Diff Against Head With Empty Plan ID Returns None + [Documentation] diff_against_head returns None for empty plan_id + ${result}= Run Process ${PYTHON} ${HELPER} diff-empty-plan-id cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} diff-empty-plan-id-ok + +Strategy Actor Resolves To None For Stub Config + [Documentation] resolve_strategy_actor returns None when config_value is "stub" + ${result}= Run Process ${PYTHON} ${HELPER} strategy-actor-stub cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} strategy-actor-stub-ok + +Strategy Actor Resolves To None Without Registry + [Documentation] resolve_strategy_actor returns None when no registry is provided + ${result}= Run Process ${PYTHON} ${HELPER} strategy-actor-no-registry cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} strategy-actor-no-registry-ok + + diff --git a/robot/helper_git_worktree_class_methods.py b/robot/helper_git_worktree_class_methods.py new file mode 100644 index 000000000..04438e59f --- /dev/null +++ b/robot/helper_git_worktree_class_methods.py @@ -0,0 +1,301 @@ +"""Robot Framework helper for GitWorktreeSandbox class methods integration tests. + +Tests cleanup_stale, diff_against_head, and resolve_strategy_actor. + +Exit code 0 = success, 1 = failure. + +Usage: + python robot/helper_git_worktree_class_methods.py cleanup-stale-no-branch + python robot/helper_git_worktree_class_methods.py cleanup-stale-removes-branch + python robot/helper_git_worktree_class_methods.py cleanup-stale-empty-plan-id + python robot/helper_git_worktree_class_methods.py diff-no-branch + python robot/helper_git_worktree_class_methods.py diff-with-changes + python robot/helper_git_worktree_class_methods.py diff-empty-plan-id + python robot/helper_git_worktree_class_methods.py strategy-actor-stub + python robot/helper_git_worktree_class_methods.py strategy-actor-no-registry +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +# Ensure the isolated repo's src directory takes precedence over any +# PYTHONPATH entries (e.g. /app/src from the workspace environment). +_SRC = str(Path(__file__).resolve().parents[1] / "src") +# Remove any conflicting paths that might shadow our isolated repo +sys.path = [p for p in sys.path if not (p.endswith("/src") and p != _SRC)] +if _SRC not in sys.path: + sys.path.insert(0, _SRC) +# Clear any cached cleveragents modules so our isolated version is used +for _mod_name in list(sys.modules.keys()): + if _mod_name == "cleveragents" or _mod_name.startswith("cleveragents."): + del sys.modules[_mod_name] + +from cleveragents.infrastructure.sandbox.git_worktree import ( # noqa: E402 + GitWorktreeSandbox, +) + + +def _init_test_repo() -> str: + """Create a temporary git repo with an initial commit.""" + repo_dir = tempfile.mkdtemp(prefix="gwt-cm-robot-") + subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + subprocess.run( + ["git", "config", "commit.gpgSign", "false"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + readme = os.path.join(repo_dir, "README.md") + with open(readme, "w") as f: + f.write("# Test Repo\n") + subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "Initial commit"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + return repo_dir + + +def cmd_cleanup_stale_no_branch() -> None: + """cleanup_stale is idempotent when no stale branch exists.""" + repo_dir = _init_test_repo() + try: + GitWorktreeSandbox.cleanup_stale(repo_dir, "plan-nonexistent-999") + print("cleanup-stale-no-branch-ok") + finally: + import shutil + + shutil.rmtree(repo_dir, ignore_errors=True) + + +def cmd_cleanup_stale_removes_branch() -> None: + """cleanup_stale removes a stale worktree branch.""" + repo_dir = _init_test_repo() + try: + plan_id = "plan-stale-001" + branch_name = f"cleveragents/plan-{plan_id}" + # Create a stale branch + subprocess.run( + ["git", "branch", branch_name], + cwd=repo_dir, + capture_output=True, + check=True, + ) + # Verify branch exists + result = subprocess.run( + ["git", "branch", "--list", branch_name], + cwd=repo_dir, + capture_output=True, + text=True, + check=True, + ) + assert branch_name in result.stdout, f"Branch {branch_name} should exist" + + # Call cleanup_stale + GitWorktreeSandbox.cleanup_stale(repo_dir, plan_id) + + # Verify branch is gone + result = subprocess.run( + ["git", "branch", "--list", branch_name], + cwd=repo_dir, + capture_output=True, + text=True, + check=True, + ) + assert branch_name not in result.stdout, ( + f"Branch {branch_name} should have been removed" + ) + print("cleanup-stale-removes-branch-ok") + finally: + import shutil + + shutil.rmtree(repo_dir, ignore_errors=True) + + +def cmd_cleanup_stale_empty_plan_id() -> None: + """cleanup_stale handles empty plan_id without raising.""" + repo_dir = _init_test_repo() + try: + GitWorktreeSandbox.cleanup_stale(repo_dir, "") + print("cleanup-stale-empty-plan-id-ok") + finally: + import shutil + + shutil.rmtree(repo_dir, ignore_errors=True) + + +def cmd_diff_no_branch() -> None: + """diff_against_head returns None when no worktree branch exists.""" + repo_dir = _init_test_repo() + try: + result = GitWorktreeSandbox.diff_against_head(repo_dir, "plan-no-branch-001") + assert result is None, f"Expected None, got: {result!r}" + print("diff-no-branch-ok") + finally: + import shutil + + shutil.rmtree(repo_dir, ignore_errors=True) + + +def cmd_diff_with_changes() -> None: + """diff_against_head returns a non-empty diff when the branch has commits.""" + repo_dir = _init_test_repo() + try: + plan_id = "plan-diff-001" + branch_name = f"cleveragents/plan-{plan_id}" + + # Create and switch to the new branch + subprocess.run( + ["git", "checkout", "-b", branch_name], + cwd=repo_dir, + capture_output=True, + check=True, + ) + + # Add a new file and commit it + new_file = os.path.join(repo_dir, "generated.py") + with open(new_file, "w") as f: + f.write("# Generated by plan\nresult = 42\n") + subprocess.run( + ["git", "add", "."], cwd=repo_dir, capture_output=True, check=True + ) + subprocess.run( + ["git", "commit", "-m", f"Plan {plan_id} output"], + cwd=repo_dir, + capture_output=True, + check=True, + ) + + # Switch back to the original branch + subprocess.run( + ["git", "checkout", "master"], + cwd=repo_dir, + capture_output=True, + check=False, + ) + # Try main if master doesn't exist + rev_result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=repo_dir, + capture_output=True, + text=True, + check=False, + ) + if rev_result.stdout.strip() not in ("master", "main"): + subprocess.run( + ["git", "checkout", "main"], + cwd=repo_dir, + capture_output=True, + check=False, + ) + + # Call diff_against_head + diff = GitWorktreeSandbox.diff_against_head(repo_dir, plan_id) + assert diff is not None, "Expected a non-None diff" + assert len(diff) > 0, "Expected a non-empty diff" + print("diff-with-changes-ok") + finally: + import shutil + + shutil.rmtree(repo_dir, ignore_errors=True) + + +def cmd_diff_empty_plan_id() -> None: + """diff_against_head returns None for empty plan_id.""" + repo_dir = _init_test_repo() + try: + result = GitWorktreeSandbox.diff_against_head(repo_dir, "") + assert result is None, f"Expected None, got: {result!r}" + print("diff-empty-plan-id-ok") + finally: + import shutil + + shutil.rmtree(repo_dir, ignore_errors=True) + + +def cmd_strategy_actor_stub() -> None: + """resolve_strategy_actor returns None when config_value is 'stub'.""" + from cleveragents.application.services.strategy_actor import resolve_strategy_actor + + registry: Any = MagicMock() + lifecycle: Any = MagicMock() + result = resolve_strategy_actor( + provider_registry=registry, + lifecycle_service=lifecycle, + config_value="stub", + ) + assert result is None, f"Expected None for stub config, got: {result!r}" + print("strategy-actor-stub-ok") + + +def cmd_strategy_actor_no_registry() -> None: + """resolve_strategy_actor returns None when no registry is provided.""" + from cleveragents.application.services.strategy_actor import resolve_strategy_actor + + lifecycle: Any = MagicMock() + result = resolve_strategy_actor( + provider_registry=None, + lifecycle_service=lifecycle, + config_value=None, + ) + assert result is None, f"Expected None for no registry, got: {result!r}" + print("strategy-actor-no-registry-ok") + + +_COMMANDS: dict[str, Any] = { + "cleanup-stale-no-branch": cmd_cleanup_stale_no_branch, + "cleanup-stale-removes-branch": cmd_cleanup_stale_removes_branch, + "cleanup-stale-empty-plan-id": cmd_cleanup_stale_empty_plan_id, + "diff-no-branch": cmd_diff_no_branch, + "diff-with-changes": cmd_diff_with_changes, + "diff-empty-plan-id": cmd_diff_empty_plan_id, + "strategy-actor-stub": cmd_strategy_actor_stub, + "strategy-actor-no-registry": cmd_strategy_actor_no_registry, +} + + +def main() -> None: + """Entry point for Robot Framework helper.""" + if len(sys.argv) < 2: + print("Usage: helper_git_worktree_class_methods.py ", file=sys.stderr) + sys.exit(1) + + cmd = sys.argv[1] + if cmd not in _COMMANDS: + print(f"Unknown command: {cmd}", file=sys.stderr) + print(f"Available: {', '.join(_COMMANDS)}", file=sys.stderr) + sys.exit(1) + + try: + _COMMANDS[cmd]() + except Exception as exc: + print(f"FAILED: {exc}", file=sys.stderr) + import traceback + + traceback.print_exc(file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index 31528c61a..f1a1bc525 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -26,8 +26,9 @@ import os import re import shutil import time +import warnings from contextlib import suppress -from datetime import UTC, datetime +from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any, Literal, cast @@ -36,6 +37,7 @@ import typer from rich.console import Console from rich.markup import escape as rich_escape from rich.panel import Panel +from rich.progress import Progress, SpinnerColumn, TextColumn from rich.table import Table from sqlalchemy.exc import SQLAlchemyError @@ -202,6 +204,19 @@ def _validate_plan_ulid(plan_id: str) -> str: return plan_id +_LEGACY_DEPRECATION_MSG = ( + "This command uses the legacy plan workflow and is deprecated.\n" + "WARNING: The legacy and v3 plan workflows are INCOMPATIBLE and cannot\n" + "be mixed. Plans created with legacy commands ('agents tell', 'agents build')\n" + "exist only in the legacy storage system and cannot be referenced by v3\n" + "commands ('agents plan execute', 'agents plan apply').\n\n" + "To migrate to the v3 workflow:\n" + " 1. Use 'agents plan use ' to create a new v3 plan.\n" + " 2. Use 'agents plan execute ' to execute it.\n" + " 3. Use 'agents plan apply ' to apply changes.\n\n" + "Do NOT attempt to use a legacy plan name with v3 commands — it will fail." +) + if TYPE_CHECKING: from cleveragents.application.services.plan_apply_service import ( PlanApplyService, @@ -209,14 +224,13 @@ if TYPE_CHECKING: from cleveragents.application.services.plan_lifecycle_service import ( PlanLifecycleService, ) - from cleveragents.domain.models.core import Project + from cleveragents.domain.models.core import Change, Plan, Project from cleveragents.domain.models.core.decision import Decision # Create sub-app for plan commands app = typer.Typer( help=( - "V3 Plan Lifecycle: Create plans with 'use', execute with 'execute', " - "apply changes with 'apply'. (Actor required; set default via " + "Plan management commands (actor required; set default via " "'agents actor set-default')" ) ) @@ -477,6 +491,246 @@ def _execute_output_dict( } +# Programmatic wrapper functions for testing and scripting +def tell_command(prompt: str, name: str | None = None) -> None: + """Programmatic interface for creating a plan from instructions. + + .. deprecated:: + Use ``PlanLifecycleService.use_action`` instead. + + Args: + prompt: Instructions for what you want the AI to do + name: Optional name for the plan + """ + warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + from cleveragents.application.services.project_service import ProjectService + + container = get_container() + plan_service: PlanService = container.plan_service() + project_service: ProjectService = container.project_service() + + # Get current project + project = project_service.get_current_project() + if not project: + raise CleverAgentsError("No project found. Run 'agents init' first.") + + # Create the plan + plan_service.create_plan(project=project, prompt=prompt, name=name) + + +def build_command( + verbose: bool = False, + actor: str | None = None, +) -> list[Change]: + """Programmatic interface for building the current plan. + + .. deprecated:: + Use ``PlanLifecycleService`` execute phase instead. + + Args: + verbose: Whether to show detailed output + actor: Optional actor name override + + Returns: + List of generated changes + """ + warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) + + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + from cleveragents.application.services.project_service import ProjectService + + container = get_container() + plan_service: PlanService = container.plan_service() + project_service: ProjectService = container.project_service() + + # Get current project + project = project_service.get_current_project() + if not project: + raise CleverAgentsError("No project found. Run 'agents init' first.") + + # Build the plan + changes = plan_service.build_plan( + project=project, + actor=actor, + ) + return changes if changes else [] + + +def apply_command(confirm: bool = True) -> int: + """Programmatic interface for applying plan changes. + + .. deprecated:: + Use ``PlanLifecycleService`` apply phase instead. + + Args: + confirm: Whether to skip confirmation (for testing) + + Returns: + Number of changes applied + """ + warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + from cleveragents.application.services.project_service import ProjectService + + container = get_container() + plan_service: PlanService = container.plan_service() + project_service: ProjectService = container.project_service() + + # Get current project + project = project_service.get_current_project() + if not project: + raise CleverAgentsError("No project found. Run 'agents init' first.") + + # Apply changes + return plan_service.apply_changes(project=project) + + +def new_command(name: str) -> None: + """Programmatic interface for creating a new empty plan. + + .. deprecated:: + Use ``PlanLifecycleService.use_action`` instead. + + Args: + name: Name for the new plan + """ + warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + from cleveragents.application.services.project_service import ProjectService + + container = get_container() + plan_service: PlanService = container.plan_service() + project_service: ProjectService = container.project_service() + + # Get current project + project = project_service.get_current_project() + if not project: + raise CleverAgentsError("No project found. Run 'agents init' first.") + + # Create new plan + plan_service.new_plan(project=project, name=name) + + +def current_command() -> Plan | None: + """Programmatic interface for getting the current plan. + + .. deprecated:: + Use ``PlanLifecycleService.get_plan`` or ``list_plans`` instead. + + Returns: + Current plan or None if no current plan + """ + warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + from cleveragents.application.services.project_service import ProjectService + + container = get_container() + plan_service: PlanService = container.plan_service() + project_service: ProjectService = container.project_service() + + # Get current project + project = project_service.get_current_project() + if not project: + raise CleverAgentsError("No project found. Run 'agents init' first.") + + # Get current plan + return plan_service.get_current_plan(project=project) + + +def list_command() -> list[Plan]: + """Programmatic interface for listing all plans. + + .. deprecated:: + Use ``PlanLifecycleService.list_plans`` instead. + + Returns: + List of all plans in the current project + """ + warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + from cleveragents.application.services.project_service import ProjectService + + container = get_container() + plan_service: PlanService = container.plan_service() + project_service: ProjectService = container.project_service() + + # Get current project + project = project_service.get_current_project() + if not project: + raise CleverAgentsError("No project found. Run 'agents init' first.") + + # Get all plans + plans = plan_service.list_plans(project=project) + return plans if plans else [] + + +def cd_command(name: str) -> None: + """Programmatic interface for switching to a different plan. + + .. deprecated:: + Use ``PlanLifecycleService.get_plan`` instead. + + Args: + name: Name of the plan to switch to + """ + warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + from cleveragents.application.services.project_service import ProjectService + + container = get_container() + plan_service: PlanService = container.plan_service() + project_service: ProjectService = container.project_service() + + # Get current project + project = project_service.get_current_project() + if not project: + raise CleverAgentsError("No project found. Run 'agents init' first.") + + # Switch to plan + plan_service.switch_to_plan(project=project, name=name) + + +def continue_command(prompt: str | None = None) -> None: + """Programmatic interface for continuing work on the current plan. + + .. deprecated:: + Use ``PlanLifecycleService`` phase methods instead. + + Args: + prompt: Optional additional instructions + """ + warnings.warn(_LEGACY_DEPRECATION_MSG, DeprecationWarning, stacklevel=2) + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + from cleveragents.application.services.project_service import ProjectService + + container = get_container() + plan_service: PlanService = container.plan_service() + project_service: ProjectService = container.project_service() + + # Get current project + project = project_service.get_current_project() + if not project: + raise CleverAgentsError("No project found. Run 'agents init' first.") + + # Continue the plan + if prompt: + plan_service.continue_plan(project=project, prompt=prompt) + else: + # Just verify there's a current plan + plan = plan_service.get_current_plan(project=project) + if not plan: + raise CleverAgentsError("No current plan to continue.") + + def _get_current_project() -> Project: """Get the current project or exit with error. @@ -500,6 +754,587 @@ def _get_current_project() -> Project: return project +async def _tell_streaming( + project: Project, + description: str, + name: str | None, + plan_service: Any, + actor: str | None = None, +) -> None: + """Handle streaming plan generation with real-time progress display. + + Args: + project: The project to create the plan in + description: Instructions for the plan + name: Optional plan name + plan_service: PlanService instance + actor: Optional actor override for streaming generation + """ + from rich.live import Live + from rich.text import Text + + # Node display names for better UX + node_names = { + "load_context": "Loading context files", + "analyze_requirements": "Analyzing requirements", + "generate_plan": "Generating plan", + "validate": "Validating plan", + } + + # Track timing for each node + node_times: dict[str, float] = {} + current_node: str | None = None + start_time = time.time() + + # Create status display + status = Text() + status.append("Starting plan generation...\n\n", style="bold cyan") + + with Live(status, console=console, refresh_per_second=4) as live: + try: + async for event in plan_service.generate_plan_streaming( + project, + description, + name, + actor=actor, + ): # type: ignore[arg-type] + # Extract node name from event + for key in event: + if key != "__end__" and key in node_names: + # Node started + if current_node and current_node in node_times: + elapsed = time.time() - node_times[current_node] + status.append( + f" [green]✓[/green] {node_names[current_node]} " + f"[dim]({elapsed:.1f}s)[/dim]\n" + ) + + current_node = key + node_times[key] = time.time() + status.append(f" [cyan]⏳[/cyan] {node_names[key]}...\n") + live.update(status) + + # Check for completion + if "__end__" in event: + if current_node and current_node in node_times: + elapsed = time.time() - node_times[current_node] + status.append( + f" [green]✓[/green] {node_names[current_node]} " + f"[dim]({elapsed:.1f}s)[/dim]\n" + ) + + total_time = time.time() - start_time + status.append( + f"\n[green]✓[/green] Plan generated successfully! " + f"[dim]Total: {total_time:.1f}s[/dim]\n" + ) + live.update(status) + + except Exception as e: + # Get user-friendly error message (without "Exception" class name) + error_msg = str(e) if str(e) else "An unknown error occurred" + + # If we were in the middle of a node, show it failed + if current_node and current_node in node_names: + elapsed = time.time() - node_times.get(current_node, time.time()) + status.append( + f" [red]✗[/red] {node_names[current_node]} failed " + f"[dim]({elapsed:.1f}s)[/dim]\n" + ) + + status.append(f"\n[red]Error:[/red] {error_msg}\n") + live.update(status) + # Re-raise the exception so callers can handle errors properly + raise + + # Show completion message (only if no exception occurred) + console.print( + Panel( + "[green]✓[/green] Plan created and built\n\n" + f"Description: {description[:100]}" + f"{'...' if len(description) > 100 else ''}\n\n" + "Next steps:\n" + " 1. Review changes with 'agents status'\n" + " 2. Run 'agents apply' to apply changes", + title="Plan Ready", + expand=False, + ) + ) + + +@app.command() +def tell( + prompt: Annotated[ + str, + typer.Argument(help="Instructions for what you want the AI to do"), + ], + name: Annotated[ + str | None, + typer.Option("--name", "-n", help="Name for the plan"), + ] = None, + actor: Annotated[ + str | None, + typer.Option( + "--actor", + help=( + "Actor to use for generation (defaults to the configured default actor)" + ), + ), + ] = None, + stream: Annotated[ + bool, + typer.Option("--stream", help="Show real-time progress during plan generation"), + ] = False, +) -> None: + """Create a new plan from natural language instructions. + + This command takes your instructions and creates a plan for code changes + that can be built and applied. + + Use --stream to see real-time progress as the AI generates the plan. + + .. deprecated:: + Use ``agents plan use`` for the v3 lifecycle. + """ + import asyncio + + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + + console.print( + "[yellow]Warning:[/yellow] 'tell' is a legacy command and is deprecated.\n" + "[yellow]WARNING:[/yellow] The legacy and v3 plan workflows are INCOMPATIBLE " + "and cannot be mixed.\n" + "Plans created here cannot be referenced by v3 commands " + "('agents plan execute', 'agents plan apply').\n" + "To use the v3 workflow: 'agents plan use '" + ) + + try: + container = get_container() + plan_service: PlanService = container.plan_service() + testing_mode = os.getenv("CLEVERAGENTS_TESTING_USE_MOCK_AI", "").lower() in ( + "true", + "yes", + "1", + ) + with suppress(Exception): + if testing_mode: + container.actor_service().ensure_default_mock_actor() + + # Get current project + project = _get_current_project() + + if stream: + # Use streaming mode for real-time progress + asyncio.run( + _tell_streaming( + project, + prompt, + name, + plan_service, + actor, + ) + ) + + else: + # Use non-streaming mode (original behavior) + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + progress.add_task("Creating plan...", total=None) + plan = plan_service.create_plan( + project=project, prompt=prompt, name=name + ) + + console.print( + Panel( + f"[green]✓[/green] Plan created: {plan.name}\n\n" + f"Prompt: {plan.prompt[:100] if plan.prompt else ''}" + f"{'...' if plan.prompt and len(plan.prompt) > 100 else ''}\n\n" + f"Next steps:\n" + f" 1. Run 'agents build' to generate changes\n" + f" 2. Run 'agents apply' to apply changes", + title="Plan Created", + expand=False, + ) + ) + + except ValidationError as e: + console.print(f"[red]Validation Error:[/red] {e.message}") + raise typer.Abort() from e + except PlanError as e: + console.print(f"[red]Plan Error:[/red] {e.message}") + raise typer.Abort() from e + except CleverAgentsError as e: + console.print(f"[red]Error:[/red] {e.message}") + raise typer.Abort() from e + + +@app.command() +def build( + verbose: Annotated[ + bool, typer.Option("--verbose", "-v", help="Show detailed output") + ] = False, + actor: Annotated[ + str | None, + typer.Option( + "--actor", + help=( + "Actor to use for building (defaults to the configured default actor)" + ), + ), + ] = None, +) -> None: + """Build the current plan to generate code changes. + + This command sends the plan and context to the selected actor + (using that actor's stored provider/model metadata) to generate + the actual code changes. + + .. deprecated:: + Use ``agents plan execute`` for the v3 lifecycle. + """ + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + + console.print( + "[yellow]Warning:[/yellow] 'build' is a legacy command and is deprecated.\n" + "[yellow]WARNING:[/yellow] The legacy and v3 plan workflows are INCOMPATIBLE " + "and cannot be mixed.\n" + "Plans created here cannot be referenced by v3 commands.\n" + "To use the v3 workflow: 'agents plan use '" + ) + + try: + container = get_container() + plan_service: PlanService = container.plan_service() + testing_mode = os.getenv("CLEVERAGENTS_TESTING_USE_MOCK_AI", "").lower() in ( + "true", + "yes", + "1", + ) + with suppress(Exception): + if testing_mode: + container.actor_service().ensure_default_mock_actor() + + # Get current project + project = _get_current_project() + + # Build the plan + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("Building plan with AI...", total=100) + + # Build with progress updates + changes = plan_service.build_plan( + project=project, + progress_callback=lambda p: progress.update(task, completed=p), + actor=actor, + ) + + if changes: + console.print( + Panel( + f"[green]✓[/green] Plan built successfully!\n\n" + f"Generated {len(changes)} change(s):\n" + + "\n".join( + f" • {c.file_path} ({c.operation})" for c in changes[:5] + ) + + ( + f"\n ... and {len(changes) - 5} more" + if len(changes) > 5 + else "" + ) + + "\n\nRun 'agents apply' to apply these changes.", + title="Build Complete", + expand=False, + ) + ) + else: + console.print("[yellow]No changes generated.[/yellow]") + + except PlanError as e: + console.print(f"[red]Build Error:[/red] {e.message}") + raise typer.Abort() from e + except CleverAgentsError as e: + console.print(f"[red]Error:[/red] {e.message}") + raise typer.Abort() from e + + +def _lifecycle_apply_with_id(plan_id: str, fmt: str = "rich") -> None: + """Run the v3 lifecycle apply for a specific plan. + + Transitions the plan through: + Execute/complete -> Apply/queued -> Apply/processing -> Apply/applied. + """ + from cleveragents.application.services.plan_lifecycle_service import ( + InvalidPhaseTransitionError, + PlanNotReadyError, + ) + + try: + # Validate ULID format before querying v3 storage. A non-ULID + # identifier (e.g., a legacy plan name) will never be found in v3 + # storage; catching it here provides an actionable error message + # instead of a generic "Plan not found". + _validate_plan_ulid(plan_id) + + service = _get_lifecycle_service() + + # Fail-fast: read-only plans must not enter Apply phase + pre_plan = service.get_plan(plan_id) + if pre_plan is None: + console.print(f"[red]Plan '{plan_id}' not found.[/red]") + raise typer.Abort() + if pre_plan.read_only is True: + console.print( + f"[red]Cannot apply plan '{plan_id}': plan is read-only.[/red]" + ) + raise typer.Abort() + + from cleveragents.domain.models.core.plan import ( + PlanPhase, + ProcessingState, + ) + + # Determine current phase and drive through apply + if ( + pre_plan.phase == PlanPhase.EXECUTE + and pre_plan.state == ProcessingState.COMPLETE + ): + # Transition Execute/complete -> Apply/queued + service.apply_plan(plan_id) + + current = service.get_plan(plan_id) + if current.phase == PlanPhase.APPLY and current.state == ProcessingState.QUEUED: + service.start_apply(plan_id) + + current = service.get_plan(plan_id) + if ( + current.phase == PlanPhase.APPLY + and current.state == ProcessingState.PROCESSING + ): + service.complete_apply(plan_id) + + plan = service.get_plan(plan_id) + + # Notify A2A facade for protocol bookkeeping + _notify_facade("plan.apply", {"plan_id": plan_id}) + + if fmt != OutputFormat.RICH.value: + data = _plan_spec_dict(plan) + console.print(format_output(data, fmt)) + else: + _print_lifecycle_plan(plan, title="Plan Applied") + console.print("\n[dim]Plan apply completed successfully.[/dim]") + + except InvalidPhaseTransitionError as e: + console.print(f"[red]Invalid transition:[/red] {e}") + raise typer.Abort() from e + except PlanNotReadyError as e: + console.print(f"[red]Plan not ready:[/red] {e}") + raise typer.Abort() from e + except ValueError as e: + # Provider-resolution failures (e.g. missing API key/config) should be + # reported as a controlled CLI error instead of bubbling to a 500. + console.print(f"[red]Execution Error:[/red] {e}") + raise typer.Abort() from e + except CleverAgentsError as e: + console.print(f"[red]Error:[/red] {e.message}") + raise typer.Abort() from e + + +@app.command() +def new( + name: Annotated[ + str, + typer.Argument(help="Name for the new plan"), + ], +) -> None: + """Create a new empty plan and switch to it. + + .. deprecated:: + Use ``agents plan use`` for the v3 lifecycle. + """ + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + + console.print( + "[yellow]Warning:[/yellow] 'new' is a legacy command. " + "Use 'agents plan use [project]' for the v3 lifecycle." + ) + + try: + container = get_container() + plan_service: PlanService = container.plan_service() + + # Create new plan + # Get the current project first + from cleveragents.application.services.project_service import ProjectService + + project_service: ProjectService = container.project_service() + current_project = project_service.get_current_project() + + if not current_project: + console.print( + "[red]Error:[/red] No project found. Run 'agents init' first." + ) + raise typer.Abort() + + plan = plan_service.new_plan(project=current_project, name=name) + + console.print(f"[green]✓[/green] Created and switched to plan: {plan.name}") + console.print("Use 'agents tell' to add instructions to this plan.") + + except ValidationError as e: + console.print(f"[red]Validation Error:[/red] {e.message}") + raise typer.Abort() from e + except CleverAgentsError as e: + console.print(f"[red]Error:[/red] {e.message}") + raise typer.Abort() from e + + +@app.command() +def current() -> None: + """Show the current active plan. + + .. deprecated:: + Use ``agents plan status`` for the v3 lifecycle. + """ + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + + console.print( + "[yellow]Warning:[/yellow] 'current' is a legacy command. " + "Use 'agents plan status [plan_id]' for the v3 lifecycle." + ) + + try: + container = get_container() + plan_service: PlanService = container.plan_service() + + # Get current project + project = _get_current_project() + + # Get current plan + plan = plan_service.get_current_plan(project=project) + + if not plan: + console.print("[yellow]No current plan.[/yellow]") + console.print( + "Create one with 'agents new ' or 'agents tell '." + ) + raise typer.Exit(0) + + # Display plan info + info_text = f""" +[bold]Current Plan:[/bold] {plan.name} +[bold]Status:[/bold] {plan.status} +[bold]Created:[/bold] {plan.created_at} +[bold]Prompt:[/bold] {plan.prompt[:200] if plan.prompt else "No prompt set"}" +"{" ... " if plan.prompt and len(plan.prompt) > 200 else ""}" + """ + + console.print(Panel(info_text.strip(), title="Current Plan", expand=False)) + + except CleverAgentsError as e: + console.print(f"[red]Error:[/red] {e.message}") + raise typer.Abort() from e + + +@app.command() +def cd( + name: Annotated[ + str, + typer.Argument(help="Name of the plan to switch to"), + ], +) -> None: + """Switch to a different plan. + + .. deprecated:: + Use ``agents plan status `` for the v3 lifecycle. + """ + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + + console.print( + "[yellow]Warning:[/yellow] 'cd' is a legacy command. " + "Use 'agents plan status ' for the v3 lifecycle." + ) + + try: + container = get_container() + plan_service: PlanService = container.plan_service() + + # Get current project + project = _get_current_project() + + # Switch to plan + plan = plan_service.switch_to_plan(project=project, name=name) + + console.print(f"[green]✓[/green] Switched to plan: {plan.name}") + + except ValidationError as e: + console.print(f"[red]Plan not found:[/red] {e.message}") + raise typer.Abort() from e + except CleverAgentsError as e: + console.print(f"[red]Error:[/red] {e.message}") + raise typer.Abort() from e + + +@app.command("continue") +def continue_plan( + prompt: Annotated[ + str | None, + typer.Argument(help="Additional instructions to continue with"), + ] = None, +) -> None: + """Continue working on the current plan. + + .. deprecated:: + Use ``agents plan use`` for the v3 lifecycle. + """ + from cleveragents.application.container import get_container + from cleveragents.application.services.plan_service import PlanService + + console.print( + "[yellow]Warning:[/yellow] 'continue' is a legacy command. " + "Use 'agents plan use [project]' for the v3 lifecycle." + ) + + try: + container = get_container() + plan_service: PlanService = container.plan_service() + + # Get current project + project = _get_current_project() + + # Continue the plan + if prompt: + plan_service.continue_plan(project=project, prompt=prompt) + console.print("[green]✓[/green] Added instructions to current plan.") + console.print("Run 'agents build' to generate new changes.") + else: + # Just continue with existing plan + plan = plan_service.get_current_plan(project=project) + if not plan: + console.print("[yellow]No current plan to continue.[/yellow]") + raise typer.Abort() + + console.print(f"[green]✓[/green] Continuing with plan: {plan.name}") + console.print("Run 'agents build' to continue building.") + + except CleverAgentsError as e: + console.print(f"[red]Error:[/red] {e.message}") + raise typer.Abort() from e + + # ============================================================================= # V3 Plan Lifecycle Commands # ============================================================================= @@ -586,40 +1421,6 @@ def _cleanup_sandbox_for_plan( GitWorktreeSandbox.cleanup_stale(resource.location, plan_id) -def _ensure_gitignore_entry(project_root: str, entry: str) -> None: - """Ensure *entry* appears in the ``.gitignore`` at *project_root*. - - Only acts when a ``.git`` directory is present (i.e. we are inside a git - repo). Appends the entry if not already present so that generated - ``plan-output/`` files are not accidentally staged and committed. - - M8 fix: plan-output/ in cwd risks accidental VCS commits because - ``git add .`` picks up generated files. Auto-adding the directory to - ``.gitignore`` prevents this. - """ - if not os.path.isdir(os.path.join(project_root, ".git")): - return # not a git repo — nothing to do - - gitignore_path = os.path.join(project_root, ".gitignore") - # Normalise: both "plan-output/" and "plan-output" are considered equivalent. - entry_normalised = entry.rstrip("/") - try: - if os.path.isfile(gitignore_path): - with open(gitignore_path) as _f: - existing = _f.read() - for line in existing.splitlines(): - if line.strip().rstrip("/") == entry_normalised: - return # already present - with open(gitignore_path, "a") as _f: - _f.write(f"\n# Auto-added by CleverAgents plan executor\n{entry}\n") - else: - with open(gitignore_path, "w") as _f: - _f.write(f"# Auto-generated by CleverAgents plan executor\n{entry}\n") - except OSError: - # Non-fatal: gitignore update is best-effort. - pass - - class _SandboxInfo: """Metadata for a per-resource sandbox.""" @@ -645,7 +1446,7 @@ def _create_sandbox_for_plan( """Create per-resource git worktree sandboxes for a plan. Per spec §19310, each resource gets its own sandbox. A parent - directory is created under ``plan-output//`` + directory is created under ``.cleveragents/sandbox//`` with per-resource subdirectories named by resource ID. Returns: @@ -661,20 +1462,6 @@ def _create_sandbox_for_plan( container = get_container() plan = service.get_plan(plan_id) - - # Guard: when plan is already execute/processing or execute/complete, - # the sandbox branch holds output awaiting apply or is actively being - # used by an in-progress execution. Do NOT destroy it via cleanup_stale. - if ( - plan is not None - and plan.phase == PlanPhase.EXECUTE - and plan.state in (ProcessingState.PROCESSING, ProcessingState.COMPLETE) - ): - flat_root = os.path.join(os.getcwd(), "plan-output", plan_id) - os.makedirs(flat_root, exist_ok=True) - _ensure_gitignore_entry(os.getcwd(), "plan-output/") - return flat_root, [] - project_names = [pl.project_name for pl in getattr(plan, "project_links", [])] sandboxes: list[_SandboxInfo] = [] @@ -730,19 +1517,18 @@ def _create_sandbox_for_plan( ) ) - # Always use local plan-output directory for better discoverability. - # This ensures users can find plan output directly in their working directory - # rather than in /tmp/ or hidden .cleveragents/ directories. - # Use the full plan_id to avoid collisions when multiple plans run - # in the same working directory (batch operations, concurrent plans). if sandboxes: + # Always use the first resource's worktree as sandbox_root + # (backward compatible — PlanExecutor and LLMExecuteActor + # write all FILE: blocks here). For multi-resource plans, + # _route_sandbox_files_to_worktrees() redistributes files + # to the correct worktrees after execute completes. return sandboxes[0].sandbox_path, sandboxes - sandbox_base = os.path.join(os.getcwd(), "plan-output", plan_id) - os.makedirs(sandbox_base, exist_ok=True) - _ensure_gitignore_entry(os.getcwd(), "plan-output/") - - return sandbox_base, sandboxes + # Fallback: flat directory sandbox + flat_root = os.path.join(os.getcwd(), ".cleveragents", "sandbox") + os.makedirs(flat_root, exist_ok=True) + return flat_root, [] def _apply_sandbox_changes( @@ -756,7 +1542,7 @@ def _apply_sandbox_changes( worktree (branch ``cleveragents/plan-`` exists), merges the branch, prints spec-aligned summary panels, and cleans up. Otherwise falls back to flat file copy from - ``plan-output//``. + ``.cleveragents/sandbox/``. Returns: ``True`` if changes were applied successfully, ``False`` if @@ -999,10 +1785,10 @@ def _apply_sandbox_changes( if merge_failed: return False - # Fallback: flat file copy from plan-output// (non-git projects). - sandbox_root = os.path.join(os.getcwd(), "plan-output", plan_id) + # 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", "plan-output"}) + _skip_dirs = frozenset({".cleveragents", ".git", ".hg", ".svn"}) if not os.path.isdir(sandbox_root): return True # No sandbox — nothing to apply, not an error @@ -1040,7 +1826,6 @@ def _apply_sandbox_changes( def _route_sandbox_files_to_worktrees( sandbox_infos: list[_SandboxInfo], - plan_output_path: str | None = None, ) -> None: """Route files from the primary sandbox to per-resource worktrees. @@ -1050,38 +1835,10 @@ def _route_sandbox_files_to_worktrees( worktrees by matching file paths against each resource's known file list (via ``git ls-files``). - Also handles the plan-output/ directory - if the LLM wrote files there - (via the discoverable sandbox path), this function copies them to the - primary worktree so they get committed. - Per spec §19310: each resource gets its own sandbox. """ import subprocess - # Handle plan-output/ → worktree copying - # The LLM writes to the discoverable plan-output/ path, but we need - # to copy those files to the worktree for commit (unless there's a - # specific worktree sandbox path) - if plan_output_path and os.path.isdir(plan_output_path): - primary = sandbox_infos[0] if sandbox_infos else None - if primary and primary.sandbox_path != plan_output_path: - # Copy all files from plan-output/ to primary worktree - for dirpath, _dirnames, filenames in os.walk(plan_output_path): - for fname in filenames: - src = os.path.join(dirpath, fname) - rel_path = os.path.relpath(src, plan_output_path) - dst = os.path.join(primary.sandbox_path, rel_path) - os.makedirs(os.path.dirname(dst), exist_ok=True) - try: - shutil.copy2(src, dst) - except OSError: - logger.warning( - "route_sandbox_file_copy_failed", - src=src, - dst=dst, - exc_info=True, - ) - if len(sandbox_infos) <= 1: return # Single resource — nothing to route @@ -1209,7 +1966,7 @@ def _recover_errored_execute_plan( current_plan.error_details = { "strategy_decisions_json": strategy_json, } - service.commit_plan(current_plan) + service.save_plan(current_plan) current_plan = service.get_plan(plan_id) if current_plan is None: console.print( @@ -1261,7 +2018,7 @@ def _recover_errored_execute_plan( "prior_error_type": error_type, "prior_error_details": json.dumps(prior_errors), } - service.commit_plan(current_plan) + service.save_plan(current_plan) # If reversion succeeded, re-run strategize with error findings if current_plan.phase == PlanPhase.STRATEGIZE: @@ -1381,8 +2138,6 @@ def _get_plan_executor( strategize_actor = resolve_strategy_actor( provider_registry=registry, lifecycle_service=lifecycle_service, - acms_pipeline=container.acms_pipeline(), - tier_service=container.context_tier_service(), config_value=config_value, ) @@ -1400,19 +2155,11 @@ def _get_plan_executor( resource_registry=container.resource_registry_service(), ) - subplan_service = container.subplan_service() - checkpoint_manager = container.checkpoint_manager() - return PlanExecutor( lifecycle_service=lifecycle_service, strategize_actor=strategize_actor, execute_actor=execute_actor, sandbox_root=sandbox_root, - checkpoint_manager=checkpoint_manager, - tier_service=container.context_tier_service(), - project_repository=container.namespaced_project_repo(), - resource_registry=container.resource_registry_service(), - subplan_service=subplan_service, ) @@ -1927,7 +2674,7 @@ def use_action( execution_environment, ] ): - service.commit_plan(plan) + service.save_plan(plan) if fmt != OutputFormat.RICH.value: data = _plan_spec_dict(plan) @@ -1997,7 +2744,6 @@ def execute_plan( ) sandbox_infos: list[_SandboxInfo] = [] - execute_succeeded = False try: from cleveragents.domain.models.core.plan import ( PlanPhase, @@ -2060,7 +2806,7 @@ def execute_plan( pre = service.get_plan(plan_id) if pre is not None: pre.execution_environment = execution_environment.lower() - service.commit_plan(pre) + service.save_plan(pre) # Create per-resource sandboxes (spec §19310) and build the # executor with the sandbox path. @@ -2144,12 +2890,8 @@ def execute_plan( plan = service.get_plan(plan_id) # Route files to correct per-resource worktrees (spec §19310) - # then commit each worktree branch. Pass sandbox_root - # (plan-output path) so it can copy files from the discoverable - # location to worktrees. - _route_sandbox_files_to_worktrees( - sandbox_infos, plan_output_path=sandbox_root - ) + # then commit each worktree branch. + _route_sandbox_files_to_worktrees(sandbox_infos) for sinfo in sandbox_infos: _commit_worktree_changes(sinfo.sandbox_path, plan_id) @@ -2177,23 +2919,15 @@ def execute_plan( ProcessingState.APPLIED, ): console.print( - f"[dim]Plan execution completed ({phase_label}). " + f"\n[dim]Plan execution completed ({phase_label}). " "Run 'agents plan apply ' when ready.[/dim]" ) - console.print( - f"[dim]Output files written to " - f"plan-output/{plan.identity.plan_id}/.[/dim]" - ) else: console.print( f"\n[dim]Plan is now in {phase_label} state. " "Run 'agents plan execute ' to continue.[/dim]" ) - # Mark execute as successful before any teardown — the worktree - # branch survives until ``plan apply`` merges it into the project. - execute_succeeded = True - except PreflightRejection as e: console.print(f"[red]Pre-flight check failed:[/red] {e}") raise typer.Abort() from e @@ -2216,24 +2950,17 @@ def execute_plan( console.print(f"[red]Unexpected error:[/red] {e}") raise typer.Abort() from e finally: - # Cleanup sandboxes only on failure — on success the worktree - # branch must survive until ``plan apply`` merges it into the - # project. We use an explicit flag instead of re-reading the - # plan from storage in-flight (which would be racy and fragile - # if an earlier exception handler already mutated the plan). - if not execute_succeeded: - for _sinfo in sandbox_infos: - try: - _sinfo.sandbox_obj.cleanup() - except Exception: - structlog.get_logger(__name__).warning( - "sandbox_cleanup_failed", - sandbox_path=getattr( - _sinfo, - "sandbox_path", - "unknown", - ), - ) + # M4: cleanup sandboxes on any failure path. + # GitWorktreeSandbox.cleanup() is idempotent — safe to call + # even after a successful apply (which already cleaned up). + for _sinfo in sandbox_infos: + try: + _sinfo.sandbox_obj.cleanup() + except Exception: + structlog.get_logger(__name__).warning( + "sandbox_cleanup_failed", + sandbox_path=getattr(_sinfo, "sandbox_path", "unknown"), + ) @app.command("apply") @@ -3073,16 +3800,13 @@ def _get_worktree_diff( def _get_apply_service() -> PlanApplyService: """Get the PlanApplyService from the lifecycle service.""" - from cleveragents.application.container import get_container from cleveragents.application.services.plan_apply_service import ( PlanApplyService, ) - container = get_container() lifecycle = _get_lifecycle_service() return PlanApplyService( lifecycle_service=lifecycle, - unit_of_work=container.unit_of_work(), ) @@ -4025,7 +4749,7 @@ def _build_explain_dict( def explain_decision_cmd( identifier: Annotated[ str, - typer.Argument(help="Decision ULID to explain"), + typer.Argument(help="Decision or Plan ULID to explain"), ], fmt: Annotated[ str, @@ -4040,7 +4764,7 @@ def explain_decision_cmd( typer.Option("--show-reasoning", help="Include rationale and actor reasoning"), ] = False, ) -> None: - """Explain a single decision in a plan.""" + """Explain a single decision or the root decision of a plan.""" from cleveragents.application.container import get_container from cleveragents.application.services.decision_service import ( DecisionNotFoundError, @@ -4049,12 +4773,24 @@ def explain_decision_cmd( container = get_container() svc = container.decision_service() - # Look up the decision by its ULID. - try: + # First, try treating the identifier as a decision_id (backward compat). + decision = None + with suppress(DecisionNotFoundError): decision = svc.get_decision(identifier) - except DecisionNotFoundError: - console.print(f"[red]Error:[/red] '{identifier}' not found as a decision.") - raise typer.Exit(1) from None + + # If not found as a decision, try as a plan_id. + if decision is None: + decisions = svc.list_decisions(identifier) + if decisions: + # Find root decision (parent_decision_id is None) + root_decisions = [d for d in decisions if d.parent_decision_id is None] + decision = root_decisions[0] if root_decisions else decisions[0] + + if decision is None: + console.print( + f"[red]Error:[/red] '{identifier}' not found as a decision or plan." + ) + raise typer.Exit(1) data = _build_explain_dict( decision, @@ -4198,131 +4934,6 @@ def _get_decision_label(decision_type: str, per_type_ordinal: int = 0) -> str: return base_label -def _build_tree_data( - plan_id: str, - tree_data: list[dict[str, object]], - decisions: list[Decision], - show_superseded: bool = False, - started_at: datetime | None = None, -) -> dict[str, object]: - """Build the data payload for ``agents plan tree --format json/yaml``. - - Returns the ``data`` dict that will be wrapped in the spec-required - command envelope by ``format_output``. - """ - filtered = ( - decisions if show_superseded else [d for d in decisions if not d.is_superseded] - ) - - def count_nodes(nodes: list[dict[str, object]]) -> int: - count = 0 - for node in nodes: - count += 1 - children = node.get("children", []) - if isinstance(children, list): - count += count_nodes(children) - return count - - def compute_depth(nodes: list[dict[str, object]]) -> int: - if not nodes: - return 0 - max_depth = 0 - for node in nodes: - children = node.get("children", []) - if isinstance(children, list) and children: - max_depth = max(max_depth, 1 + compute_depth(children)) - return max_depth - - nodes_count = count_nodes(tree_data) - tree_depth = compute_depth(tree_data) - - child_plan_ids: set[str] = set() - for d in filtered: - if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn") and d.plan_id: - child_plan_ids.add(d.plan_id) - - child_plans_count = len(child_plan_ids) - child_plans_str = f"{child_plans_count}+" if child_plans_count > 0 else "0" - - invariants_count = sum( - 1 for d in filtered if d.decision_type == "invariant_enforced" - ) - - superseded_count = sum(1 for d in decisions if d.is_superseded) - - summary = { - "nodes": nodes_count, - "depth": tree_depth, - "child_plans": child_plans_str, - "invariants": invariants_count, - "superseded": superseded_count, - } - - type_counts: dict[str, int] = {} - decision_ids: dict[str, str] = {} - - for d in filtered: - type_counts[d.decision_type] = type_counts.get(d.decision_type, 0) + 1 - ordinal = type_counts[d.decision_type] - - if d.decision_type == "prompt_definition": - key = "root" - elif d.decision_type == "invariant_enforced": - key = f"invariant_{ordinal}" - elif d.decision_type == "strategy_choice": - key = "strategy" - elif d.decision_type == "implementation_choice": - key = f"implementation_{ordinal}" - elif d.decision_type == "subplan_spawn": - key = f"spawn_{ordinal}" - elif d.decision_type == "subplan_parallel_spawn": - key = f"parallel_{ordinal}" - else: - key = f"{d.decision_type}_{ordinal}" - - decision_ids[key] = d.decision_id - - child_plans_list: list[dict[str, object]] = [] - for d in filtered: - if d.decision_type in ("subplan_spawn", "subplan_parallel_spawn") and d.plan_id: - child_plans_list.append( - { - "id": d.plan_id, - "phase": "execute", - "state": "queued", - } - ) - - def convert_tree_node(node: dict[str, object]) -> dict[str, object]: - """Convert internal tree node format to spec format.""" - spec_node: dict[str, object] = { - "type": node.get("type"), - "description": node.get("question") or node.get("description"), - } - - if node.get("confidence") is not None: - spec_node["confidence"] = node.get("confidence") - - if node.get("type") in ("subplan_spawn", "subplan_parallel_spawn"): - spec_node["plan_id"] = node.get("plan_id", "") - - children = node.get("children", []) - if isinstance(children, list) and children: - spec_node["children"] = [convert_tree_node(child) for child in children] - - return spec_node - - spec_tree = convert_tree_node(tree_data[0]) if tree_data else None - - return { - "plan_id": plan_id, - "tree": spec_tree, - "summary": summary, - "child_plans": child_plans_list, - "decision_ids": decision_ids, - } - - @app.command("tree") def tree_decisions_cmd( plan_id: Annotated[ @@ -4345,7 +4956,6 @@ def tree_decisions_cmd( """Display the decision tree for a plan.""" from cleveragents.application.container import get_container - _tree_cmd_start = datetime.now(UTC) container = get_container() svc = container.decision_service() decisions = svc.list_decisions(plan_id) @@ -4360,17 +4970,7 @@ def tree_decisions_cmd( ) if fmt in (OutputFormat.JSON, OutputFormat.YAML): - tree_data_dict = _build_tree_data( - plan_id, tree_data, decisions, show_superseded, started_at=_tree_cmd_start - ) - console.print( - format_output( - tree_data_dict, - fmt, - command="plan tree", - messages=[{"level": "ok", "text": "Decision tree rendered"}], - ) - ) + console.print(format_output(tree_data, fmt)) elif fmt == OutputFormat.TABLE: # Flatten for table view filtered = ( @@ -4501,218 +5101,3 @@ def tree_decisions_cmd( expand=False, ) ) - - -# --------------------------------------------------------------------------- -# plan checkpoint-list / checkpoint-delete -# --------------------------------------------------------------------------- - - -@app.command("checkpoint-list") -def checkpoint_list_cmd( - plan_id: Annotated[ - str, - typer.Argument(help="Plan ID (ULID) to list checkpoints for"), - ], - sort: Annotated[ - str, - typer.Option( - "--sort", - help="Sort order: asc (oldest first) or desc (newest first)", - ), - ] = "asc", - checkpoint_type: Annotated[ - str | None, - typer.Option( - "--type", - help="Filter by type: pre_write, post_step, manual, pre_decision", - ), - ] = None, - fmt: Annotated[ - str, - typer.Option( - "--format", - "-f", - help=_FORMAT_HELP, - ), - ] = "rich", -) -> None: - """List all checkpoints for a plan. - - Displays checkpoint ID, timestamp, type, and state summary for each - checkpoint associated with the given plan. - - Examples:: - - agents plan checkpoint-list PLAN123 - agents plan checkpoint-list PLAN123 --sort desc - agents plan checkpoint-list PLAN123 --type manual - agents plan checkpoint-list PLAN123 --format json - """ - from cleveragents.application.container import get_container - from cleveragents.core.exceptions import ResourceNotFoundError as RNF - - try: - container = get_container() - svc = container.checkpoint_service() - - checkpoints = svc.list_checkpoints(plan_id) - - # Apply type filter - if checkpoint_type is not None: - checkpoints = [ - cp for cp in checkpoints if cp.checkpoint_type == checkpoint_type - ] - - # Apply sort order - reverse = sort.lower() == "desc" - checkpoints = sorted(checkpoints, key=lambda cp: cp.created_at, reverse=reverse) - - if fmt != OutputFormat.RICH.value: - data: list[dict[str, object]] = [ - { - "checkpoint_id": cp.checkpoint_id, - "plan_id": cp.plan_id, - "checkpoint_type": cp.checkpoint_type, - "sandbox_ref": cp.sandbox_ref, - "created_at": cp.created_at.isoformat(), - "reason": cp.metadata.reason, - "phase": cp.metadata.phase, - "decision_id": cp.decision_id, - } - for cp in checkpoints - ] - console.print(format_output(data, fmt)) - return - - if not checkpoints: - console.print(f"[dim]No checkpoints found for plan {plan_id}.[/dim]") - return - - table = Table(title=f"Checkpoints for Plan {plan_id}", show_header=True) - table.add_column("Checkpoint ID", style="cyan", max_width=26) - table.add_column("Checkpoint Type", style="yellow") - table.add_column("Created", style="green") - table.add_column("Reason") - table.add_column("Phase") - table.add_column("Decision ID", style="dim", max_width=26) - - for cp in checkpoints: - table.add_row( - cp.checkpoint_id, - cp.checkpoint_type, - _format_relative_time(cp.created_at), - cp.metadata.reason or "(none)", - cp.metadata.phase or "(none)", - cp.decision_id or "(none)", - ) - - console.print(table) - console.print( - "[dim]Fields: checkpoint_id, checkpoint_type, created_at, reason, " - "phase, decision_id[/dim]" - ) - cp_word = "checkpoint" if len(checkpoints) == 1 else "checkpoints" - console.print( - f"[green bold]✓ OK[/green bold] {len(checkpoints)} {cp_word} listed" - ) - - except RNF as e: - console.print(f"[red]Not found:[/red] {e.message}") - raise typer.Abort() from e - except CleverAgentsError as e: - console.print(f"[red]Error:[/red] {e.message}") - raise typer.Abort() from e - - -@app.command("checkpoint-delete") -def checkpoint_delete_cmd( - checkpoint_ids: Annotated[ - list[str] | None, - typer.Argument( - help="One or more checkpoint IDs to delete", - metavar="CHECKPOINT_ID", - ), - ] = None, - yes: Annotated[ - bool, - typer.Option( - "--yes", - "-y", - help="Skip confirmation prompt", - ), - ] = False, - fmt: Annotated[ - str, - typer.Option( - "--format", - "-f", - help=_FORMAT_HELP, - ), - ] = "rich", -) -> None: - """Delete one or more checkpoints by ID. - - Accepts one or more checkpoint IDs as positional arguments. - Prompts for confirmation unless --yes is supplied. - - Examples:: - - agents plan checkpoint-delete CP123 - agents plan checkpoint-delete CP123 CP456 --yes - agents plan checkpoint-delete CP123 --format json - """ - from cleveragents.application.container import get_container - from cleveragents.core.exceptions import ResourceNotFoundError as RNF - - ids: list[str] = list(checkpoint_ids or []) - if not ids: - console.print("[red]Error:[/red] At least one checkpoint ID is required.") - raise typer.Abort() - - if not yes: - cp_word = "checkpoint" if len(ids) == 1 else "checkpoints" - ids_display = ", ".join(ids) - confirm = typer.confirm(f"Delete {len(ids)} {cp_word}: {ids_display}?") - if not confirm: - console.print("[yellow]Deletion cancelled.[/yellow]") - raise typer.Abort() - - container = get_container() - svc = container.checkpoint_service() - - deleted: list[str] = [] - errors: list[dict[str, str]] = [] - - for cp_id in ids: - try: - svc.delete_checkpoint(cp_id) - deleted.append(cp_id) - except RNF: - errors.append({"checkpoint_id": cp_id, "error": "not found"}) - except CleverAgentsError as e: - errors.append({"checkpoint_id": cp_id, "error": e.message}) - - if fmt != OutputFormat.RICH.value: - result_data: dict[str, object] = { - "deleted": deleted, - "errors": errors, - "deleted_count": len(deleted), - "error_count": len(errors), - } - console.print(format_output(result_data, fmt)) - return - - if deleted: - cp_word = "checkpoint" if len(deleted) == 1 else "checkpoints" - console.print(f"[green bold]✓ OK[/green bold] {len(deleted)} {cp_word} deleted") - for cp_id in deleted: - console.print(f" [dim]Deleted:[/dim] {cp_id}") - - if errors: - for err in errors: - cp_id_val = err["checkpoint_id"] - err_val = err["error"] - console.print(f"[red]Error:[/red] {cp_id_val} — {err_val}") - if not deleted: - raise typer.Abort() diff --git a/src/cleveragents/infrastructure/sandbox/git_worktree.py b/src/cleveragents/infrastructure/sandbox/git_worktree.py index 3974679b8..ef15fcf67 100644 --- a/src/cleveragents/infrastructure/sandbox/git_worktree.py +++ b/src/cleveragents/infrastructure/sandbox/git_worktree.py @@ -164,127 +164,6 @@ class GitWorktreeSandbox: """Context after creation, ``None`` before ``create``.""" return self._context - # -- class helpers ------------------------------------------------------- - - @classmethod - def cleanup_stale(cls, repo_path: str, plan_id: str) -> bool: - """Remove a stale worktree branch left by a previous execution. - - Idempotent — does nothing if no stale branch exists. - - Args: - repo_path: Absolute path to the git repository root. - plan_id: The plan ULID whose stale branch should be removed. - - Returns: - ``True`` if a stale branch was found and cleaned up, - ``False`` if no stale branch existed. - """ - branch_name = f"cleveragents/plan-{plan_id}" - - try: - _run_git( - ["rev-parse", "--verify", f"refs/heads/{branch_name}"], - cwd=repo_path, - ) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - return False - - logger.info( - "Cleaning up stale sandbox branch: branch=%s repo=%s", - branch_name, - repo_path, - ) - - try: - wt_result = _run_git( - ["worktree", "list", "--porcelain"], - cwd=repo_path, - ) - for wt_block in wt_result.stdout.split("\n\n"): - if f"branch refs/heads/{branch_name}" in wt_block: - for line in wt_block.splitlines(): - if line.startswith("worktree "): - wt_path = line.split("worktree ", 1)[1] - try: - _run_git( - ["worktree", "remove", "--force", wt_path], - cwd=repo_path, - ) - except ( - subprocess.CalledProcessError, - subprocess.TimeoutExpired, - ): - logger.warning( - "git worktree remove failed; " - "removing directory manually: %s", - wt_path, - ) - shutil.rmtree(wt_path, ignore_errors=True) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - logger.warning( - "Failed to list worktrees for stale cleanup: %s", - branch_name, - ) - - branch_deleted = True - try: - _run_git(["branch", "-D", branch_name], cwd=repo_path) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - branch_deleted = False - logger.warning( - "Failed to delete stale branch %s", - branch_name, - ) - - with contextlib.suppress( - subprocess.CalledProcessError, - subprocess.TimeoutExpired, - ): - _run_git(["worktree", "prune"], cwd=repo_path) - - if branch_deleted: - logger.info("Stale sandbox branch cleaned up: branch=%s", branch_name) - else: - logger.warning( - "Partial cleanup: worktree removed but branch persists: branch=%s", - branch_name, - ) - return True - - @classmethod - def diff_against_head(cls, repo_path: str, plan_id: str) -> str | None: - """Return a unified diff of the worktree branch vs HEAD. - - Args: - repo_path: Absolute path to the git repository root. - plan_id: The plan ULID whose worktree branch to diff. - - Returns: - The diff text, or ``None`` if no worktree branch exists. - """ - branch_name = f"cleveragents/plan-{plan_id}" - - try: - _run_git( - ["rev-parse", "--verify", f"refs/heads/{branch_name}"], - cwd=repo_path, - ) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - return None - - try: - result = _run_git( - ["diff", f"HEAD...{branch_name}"], - cwd=repo_path, - timeout=30, - ) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired): - return None - - diff_text = result.stdout.strip() - return diff_text if diff_text else "No changes in worktree branch." - # -- protocol methods ---------------------------------------------------- def create(self, plan_id: str) -> SandboxContext: @@ -684,6 +563,143 @@ class GitWorktreeSandbox: self._base_commit, ) + @classmethod + def cleanup_stale(cls, original_path: str, plan_id: str) -> None: + """Remove any stale worktree and branch left by a previous execute. + + Looks for a worktree branch named ``cleveragents/plan-`` + in the repository at *original_path* and removes it if found. + Idempotent — safe to call even when no stale sandbox exists. + + Args: + original_path: Path to the git repository root. + plan_id: The plan ID whose stale sandbox should be removed. + """ + if not original_path or not plan_id: + return + + safe_plan_id = _sanitise_branch_name(plan_id) + branch_name = f"cleveragents/plan-{safe_plan_id}" + + try: + # List all worktrees to find any matching this plan + result = subprocess.run( + ["git", "worktree", "list", "--porcelain"], + cwd=original_path, + capture_output=True, + text=True, + check=False, + timeout=_GIT_TIMEOUT, + ) + if result.returncode != 0: + return + + # Parse worktree list output + current_wt_path: str | None = None + current_branch: str | None = None + for line in result.stdout.splitlines(): + if line.startswith("worktree "): + current_wt_path = line.split("worktree ", 1)[1].strip() + current_branch = None + elif line.startswith("branch "): + current_branch = line.split("branch ", 1)[1].strip() + # branch refs/heads/cleveragents/plan-... + if current_branch.endswith(branch_name) and current_wt_path: + # Remove the stale worktree + subprocess.run( + ["git", "worktree", "remove", "--force", current_wt_path], + cwd=original_path, + capture_output=True, + check=False, + timeout=_GIT_TIMEOUT, + ) + current_wt_path = None + current_branch = None + + # Delete the stale branch if it exists + subprocess.run( + ["git", "branch", "-D", branch_name], + cwd=original_path, + capture_output=True, + check=False, + timeout=_GIT_TIMEOUT, + ) + + # Prune stale worktree entries + subprocess.run( + ["git", "worktree", "prune"], + cwd=original_path, + capture_output=True, + check=False, + timeout=_GIT_TIMEOUT, + ) + + except (subprocess.TimeoutExpired, OSError): + logger.debug( + "cleanup_stale: error during stale sandbox cleanup " + "(original_path=%s, plan_id=%s)", + original_path, + plan_id, + ) + + @classmethod + def diff_against_head(cls, original_path: str, plan_id: str) -> str | None: + """Return a unified diff of the worktree branch against HEAD. + + Looks for a worktree branch named ``cleveragents/plan-`` + in the repository at *original_path* and returns a unified diff + of that branch against HEAD. Returns ``None`` when no such branch + exists. + + Args: + original_path: Path to the git repository root. + plan_id: The plan ID whose worktree branch to diff. + + Returns: + A unified diff string, or ``None`` if no worktree branch exists. + """ + if not original_path or not plan_id: + return None + + safe_plan_id = _sanitise_branch_name(plan_id) + branch_name = f"cleveragents/plan-{safe_plan_id}" + + try: + # Check if the branch exists + check = subprocess.run( + ["git", "rev-parse", "--verify", branch_name], + cwd=original_path, + capture_output=True, + check=False, + timeout=_GIT_TIMEOUT, + ) + if check.returncode != 0: + return None + + # Generate diff between HEAD and the worktree branch + diff_result = subprocess.run( + ["git", "diff", "HEAD", branch_name], + cwd=original_path, + capture_output=True, + text=True, + check=False, + timeout=_GIT_TIMEOUT, + ) + if diff_result.returncode != 0: + return None + + diff_output = diff_result.stdout.strip() + return diff_output if diff_output else None + + except (subprocess.TimeoutExpired, OSError): + logger.debug( + "diff_against_head: error generating worktree diff " + "(original_path=%s, plan_id=%s)", + original_path, + plan_id, + ) + return None + def cleanup(self) -> None: """Remove the worktree and sandbox branch.