fix(plan): guard cleanup_stale against execute/processing and execute/complete plans #11127

Merged
hurui200320 merged 2 commits from bugfix/m3-cleanup-stale-destroys-execute-output into master 2026-05-12 11:28:47 +00:00
7 changed files with 408 additions and 3 deletions
+9
View File
@@ -29,6 +29,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Plan Rollback Command** (#8557): Implemented `agents plan rollback <plan-id> [<checkpoint-id>]` for checkpoint-based plan state restoration in Epic #8493. The command restores a plan's sandbox to the state captured at a given checkpoint, discarding all decisions made after that checkpoint. The checkpoint can be specified as an optional positional second argument or via the `--to-checkpoint` named option. Supports `--yes/-y` flag to skip confirmation prompts and `--format/-f` for output format selection (rich/plain/json/yaml). Included with comprehensive BDD test coverage (>= 97%) and spec-aligned output formatting showing rollback summary, changes reverted, impact analysis, and post-rollback state panels.
### Fixed
- **Guard cleanup_stale against execute/processing and execute/complete plans** (#11121):
``_create_sandbox_for_plan()`` in ``src/cleveragents/cli/commands/plan.py`` now
skips ``GitWorktreeSandbox.cleanup_stale()`` when the plan is in
``execute/processing`` (execution in progress) or ``execute/complete`` (execution
finished, awaiting apply) state. Previously, re-invoking ``agents plan execute``
on a completed plan would silently destroy the ``cleveragents/plan-<id>`` git
worktree branch, causing ``plan apply`` to merge zero artifacts. The guard
preserves the branch per spec (§sandbox.cleanup defaults to ``on_apply``).
- **Global CLI options ``--data-dir``, ``--config-path``, and ``-v`` now work correctly**
(#6785): These spec-required flags were absent from ``main_callback()`` in
``src/cleveragents/cli/main.py``, causing any invocation with these flags to crash
@@ -459,7 +459,13 @@ def step_have_state_with_retry_count(context: Any, count: int) -> None:
"I check uncovered langgraph should_retry with FAIL validation and retry_count {count:d}"
)
def step_check_should_retry_uncovered(context: Any, count: int) -> None:
"""Check should_retry and verify retry_count increment."""
"""Check should_retry returns correct decision.
_should_retry is a conditional-edge function — LangGraph treats it as
read-only. The retry counter is already incremented inside _validate
(a node whose return dict IS merged into the state), so _handle_retry
is a simple pass-through bridge node.
"""
decision = context.graph._should_retry(context.state)
context.retry_decision = decision
context.final_retry_count = context.state.get("retry_count")
@@ -0,0 +1,318 @@
"""Steps for tdd_cleanup_stale_destroys_execute_output.feature.
TDD issue-capture test for bug #11121:
_create_sandbox_for_plan() calls GitWorktreeSandbox.cleanup_stale() unconditionally,
destroying the cleveragents/plan-<id> branch when the plan is already in
execute/complete or execute/processing state (awaiting apply or still in progress).
The scenarios are tagged @tdd_issue_11121 as permanent regression guards.
"""
from __future__ import annotations
import contextlib
import shutil
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
_PLAN_ID = "01TDDSANDBOX000000000000A"
_BRANCH_NAME = 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=15,
)
def _branch_exists(repo_path: str, branch: str) -> bool:
"""Return True if the given branch exists in the repo."""
result = subprocess.run(
["git", "rev-parse", "--verify", f"refs/heads/{branch}"],
cwd=repo_path,
capture_output=True,
text=True,
check=False,
timeout=10,
)
return result.returncode == 0
def _init_git_repo(path: str) -> None:
_git(["init", "-q", "-b", "main"], path)
_git(["config", "user.name", "TDD Test"], path)
_git(["config", "user.email", "tdd@test.local"], path)
_git(["config", "commit.gpgsign", "false"], path)
def _build_execute_complete_mocks(
context: Context,
repo_path: str,
) -> None:
"""Build mock service + container for a plan in execute/complete state."""
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState
mock_resource = MagicMock()
mock_resource.resource_type_name = "git-checkout"
mock_resource.location = repo_path
mock_resource.resource_id = "res-tdd-11121-test"
mock_lr = MagicMock()
mock_lr.resource_id = "res-tdd-11121-test"
mock_project = MagicMock()
mock_project.linked_resources = [mock_lr]
# Plan is in execute/complete state — this is the critical state for the bug
mock_plan = MagicMock()
mock_plan.project_links = [MagicMock(project_name="local/tdd-11121-project")]
mock_plan.phase = PlanPhase.EXECUTE
mock_plan.processing_state = ProcessingState.COMPLETE
mock_plan.state = ProcessingState.COMPLETE
mock_service = MagicMock()
mock_service.get_plan.return_value = mock_plan
mock_project_repo = MagicMock()
mock_project_repo.get.return_value = mock_project
mock_resource_registry = MagicMock()
mock_resource_registry.show_resource.return_value = mock_resource
mock_container = MagicMock()
mock_container.namespaced_project_repo.return_value = mock_project_repo
mock_container.resource_registry_service.return_value = mock_resource_registry
context.tdd11121_service = mock_service
context.tdd11121_container = mock_container
context.tdd11121_repo_path = repo_path
context.tdd11121_plan_id = _PLAN_ID
context.tdd11121_branch = _BRANCH_NAME
@given("a temp git repo with an execute-output branch for tdd 11121")
def step_create_git_repo_with_execute_output(context: Context) -> None:
"""Create a real git repo with a cleveragents/plan-<id> branch holding execute output.
This simulates the state after a successful plan execute: the worktree branch
exists and contains at least one committed file representing execution output.
"""
d = tempfile.mkdtemp(prefix="tdd-11121-")
context.add_cleanup(shutil.rmtree, d, True)
# Initialise the repo with a base commit on main
_init_git_repo(d)
Path(d, "README.md").write_text("# project\n")
_git(["add", "."], d)
_git(["commit", "-q", "-m", "init: base commit"], d)
# Create the cleveragents/plan-<id> branch and commit an output file to it.
# This simulates what _commit_worktree_changes() does after execute completes.
_git(["checkout", "-q", "-b", _BRANCH_NAME], d)
output_file = Path(d, "generated_output.py")
output_file.write_text("# Generated by plan execute\nresult = 42\n")
_git(["add", "."], d)
_git(["commit", "-q", "-m", f"cleveragents: execute output for plan {_PLAN_ID}"], d)
# Return to main so the repo is in a normal state
_git(["checkout", "-q", "main"], d)
context.tdd11121_repo_path = d
# Verify the branch exists before the test
assert _branch_exists(d, _BRANCH_NAME), (
f"Pre-condition failed: branch {_BRANCH_NAME} should exist before test"
)
def _build_execute_processing_mocks(
context: Context,
repo_path: str,
) -> None:
"""Build mock service + container for a plan in execute/processing state."""
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState
mock_resource = MagicMock()
mock_resource.resource_type_name = "git-checkout"
mock_resource.location = repo_path
mock_resource.resource_id = "res-tdd-11121-test"
mock_lr = MagicMock()
mock_lr.resource_id = "res-tdd-11121-test"
mock_project = MagicMock()
mock_project.linked_resources = [mock_lr]
mock_plan = MagicMock()
mock_plan.project_links = [MagicMock(project_name="local/tdd-11121-project")]
mock_plan.phase = PlanPhase.EXECUTE
mock_plan.processing_state = ProcessingState.PROCESSING
mock_plan.state = ProcessingState.PROCESSING
mock_service = MagicMock()
mock_service.get_plan.return_value = mock_plan
mock_project_repo = MagicMock()
mock_project_repo.get.return_value = mock_project
mock_resource_registry = MagicMock()
mock_resource_registry.show_resource.return_value = mock_resource
mock_container = MagicMock()
mock_container.namespaced_project_repo.return_value = mock_project_repo
mock_container.resource_registry_service.return_value = mock_resource_registry
context.tdd11121_service = mock_service
context.tdd11121_container = mock_container
context.tdd11121_repo_path = repo_path
context.tdd11121_plan_id = _PLAN_ID
context.tdd11121_branch = _BRANCH_NAME
@given("a mocked plan service with the plan in execute/complete state for tdd 11121")
def step_mock_service_execute_complete(context: Context) -> None:
"""Set up mock service returning a plan in execute/complete state."""
_build_execute_complete_mocks(context, context.tdd11121_repo_path)
@given("a mocked plan service with the plan in execute/processing state for tdd 11121")
def step_mock_service_execute_processing(context: Context) -> None:
"""Set up mock service returning a plan in execute/processing state."""
_build_execute_processing_mocks(context, context.tdd11121_repo_path)
@when(
"I call _create_sandbox_for_plan a second time on the execute/complete plan for tdd 11121"
)
def step_call_create_sandbox_second_time(context: Context) -> None:
"""Call _create_sandbox_for_plan on a plan already in execute/complete state.
This simulates the user re-running 'agents plan execute <PLAN_ID>' after
execution has already completed. The bug causes cleanup_stale to run and
destroy the cleveragents/plan-<id> branch that holds the execute output.
"""
from cleveragents.cli.commands.plan import _create_sandbox_for_plan
context.tdd11121_second_call_exception: Exception | None = None
try:
with (
patch(
"cleveragents.cli.commands.plan.get_container",
return_value=context.tdd11121_container,
),
patch(
"cleveragents.application.container.get_container",
return_value=context.tdd11121_container,
),
):
_sandbox_root, sandbox_infos = _create_sandbox_for_plan(
context.tdd11121_plan_id,
context.tdd11121_service,
)
context.tdd11121_sandbox_infos = sandbox_infos
# Clean up any newly created sandbox to avoid resource leaks
for sinfo in sandbox_infos:
with contextlib.suppress(Exception):
sinfo.sandbox_obj.cleanup()
except Exception as exc:
# Record but don't re-raise — we want to check branch state regardless
context.tdd11121_second_call_exception = exc
@when(
"I call _create_sandbox_for_plan a second time on the execute/processing plan for tdd 11121"
)
def step_call_create_sandbox_second_time_processing(context: Context) -> None:
"""Call _create_sandbox_for_plan on a plan already in execute/processing state.
This simulates the user re-running 'agents plan execute <PLAN_ID>' while the
plan is still in progress (execute/processing). The guard must protect the
sandbox branch from cleanup_stale in this state too.
"""
from cleveragents.cli.commands.plan import _create_sandbox_for_plan
context.tdd11121_second_call_exception: Exception | None = None
try:
with (
patch(
"cleveragents.cli.commands.plan.get_container",
return_value=context.tdd11121_container,
),
patch(
"cleveragents.application.container.get_container",
return_value=context.tdd11121_container,
),
):
_sandbox_root, sandbox_infos = _create_sandbox_for_plan(
context.tdd11121_plan_id,
context.tdd11121_service,
)
context.tdd11121_sandbox_infos = sandbox_infos
for sinfo in sandbox_infos:
with contextlib.suppress(Exception):
sinfo.sandbox_obj.cleanup()
except Exception as exc:
context.tdd11121_second_call_exception = exc
@then(
"the cleveragents plan branch should still exist after the second call for tdd 11121"
)
def step_branch_still_exists(context: Context) -> None:
"""Assert the cleveragents/plan-<id> branch was NOT destroyed by cleanup_stale.
The guard in _create_sandbox_for_plan skips cleanup_stale when the plan is
already in execute/processing or execute/complete state, preserving the
sandbox branch so plan apply can merge it.
"""
repo_path = context.tdd11121_repo_path
branch = context.tdd11121_branch
assert _branch_exists(repo_path, branch), (
f"Bug #11121: branch '{branch}' was destroyed by cleanup_stale during "
f"a second _create_sandbox_for_plan call. "
f"The branch must survive until plan apply merges it."
)
@then("the apply sandbox changes should find at least one artifact for tdd 11121")
def step_apply_finds_artifacts(context: Context) -> None:
"""Assert that apply can find the execute output after a re-invoked execute.
The guard in _create_sandbox_for_plan preserves the sandbox branch, so the
git diff between HEAD and the plan branch finds at least one artifact.
"""
repo_path = context.tdd11121_repo_path
branch = context.tdd11121_branch
if not _branch_exists(repo_path, branch):
raise AssertionError(
f"Bug #11121: branch '{branch}' was destroyed by cleanup_stale. "
f"plan apply would find 0 artifacts (empty changeset). "
f"The branch must persist until apply merges it."
)
result = subprocess.run(
["git", "diff", "--stat", f"HEAD...{branch}"],
cwd=repo_path,
capture_output=True,
text=True,
check=False,
timeout=10,
)
stat_lines = (result.stdout or "").strip().splitlines()
artifact_count = max(0, len(stat_lines) - 1) if stat_lines else 0
assert artifact_count > 0, (
f"Bug #11121: plan apply would find {artifact_count} artifacts after "
f"re-invoked execute on execute/complete plan. Expected >= 1 artifact. "
f"diff --stat output: {result.stdout!r}"
)
@@ -0,0 +1,33 @@
@tdd_issue @tdd_issue_11121
Feature: TDD Issue #11121 — cleanup_stale destroys git worktree branch on re-invoked execute
As a developer
I want to verify that _create_sandbox_for_plan does NOT delete the cleveragents/plan-<id>
branch when the plan is already in execute/complete state
So that plan apply can subsequently find and merge the correct artifacts
Bug #11121: _create_sandbox_for_plan() calls GitWorktreeSandbox.cleanup_stale()
unconditionally every time agents plan execute is invoked including when the plan
is already in execute/complete state (execution finished, awaiting apply). This deletes
the cleveragents/plan-<id> git branch that holds the execution output, so when
agents plan apply runs next it finds no branch and merges zero artifacts.
@tdd_issue @tdd_issue_11121 @mock_only
Outdated
Review

BLOCKING — @tdd_expected_fail must be removed by the fix commit.

This TDD regression test correctly uses @tdd_issue @tdd_issue_11121 (the tag correction from prior review is confirmed — good). However, both scenarios still carry @tdd_expected_fail. The fix in commit 9c55e45a makes these scenarios pass, so the expected-fail marker must now be removed.

The CI / tdd_quality_gate job is failing because it sees: PR closes bug #11121 → finds TDD test for #11121@tdd_expected_fail is still present → FAIL.

Required change — remove @tdd_expected_fail from both scenario tags:

  @tdd_issue @tdd_issue_11121 @mock_only
  Scenario: cleveragents/plan-<id> branch survives ...

  @tdd_issue @tdd_issue_11121 @mock_only
  Scenario: plan apply finds non-zero artifacts ...

Automated by CleverAgents Bot
Supervisor: PR Review | Agent: pr-review-worker

**BLOCKING — `@tdd_expected_fail` must be removed by the fix commit.** This TDD regression test correctly uses `@tdd_issue @tdd_issue_11121` (the tag correction from prior review is confirmed — good). However, both scenarios still carry `@tdd_expected_fail`. The fix in commit `9c55e45a` makes these scenarios pass, so the expected-fail marker must now be removed. The `CI / tdd_quality_gate` job is failing because it sees: PR closes bug `#11121` → finds TDD test for `#11121` → `@tdd_expected_fail` is still present → **FAIL**. Required change — remove `@tdd_expected_fail` from both scenario tags: ```gherkin @tdd_issue @tdd_issue_11121 @mock_only Scenario: cleveragents/plan-<id> branch survives ... @tdd_issue @tdd_issue_11121 @mock_only Scenario: plan apply finds non-zero artifacts ... ``` --- Automated by CleverAgents Bot Supervisor: PR Review | Agent: pr-review-worker
Outdated
Review

BLOCKING — @tdd_expected_fail must be removed (carry-over from review #8647 BLOCKER A, unchanged).

Both scenarios still carry @tdd_expected_fail. The fix in this PR makes these scenarios pass, so the expected-fail marker must be removed. This is directly causing CI / unit_tests to fail: the inversion logic sees a scenario tagged @tdd_expected_fail that is now passing, and flips it to a failure.

Remove @tdd_expected_fail from both scenario tag lines:

  @tdd_issue @tdd_issue_11121 @mock_only
  Scenario: cleveragents/plan-<id> branch survives a second _create_sandbox_for_plan call when plan is execute/complete

  @tdd_issue @tdd_issue_11121 @mock_only
  Scenario: plan apply finds non-zero artifacts after re-invoked execute on execute/complete plan

Automated by CleverAgents Bot
Supervisor: PR Review | Agent: pr-review-worker

**BLOCKING — `@tdd_expected_fail` must be removed (carry-over from review #8647 BLOCKER A, unchanged).** Both scenarios still carry `@tdd_expected_fail`. The fix in this PR makes these scenarios pass, so the expected-fail marker must be removed. This is directly causing `CI / unit_tests` to fail: the inversion logic sees a scenario tagged `@tdd_expected_fail` that is now passing, and flips it to a failure. Remove `@tdd_expected_fail` from both scenario tag lines: ```gherkin @tdd_issue @tdd_issue_11121 @mock_only Scenario: cleveragents/plan-<id> branch survives a second _create_sandbox_for_plan call when plan is execute/complete @tdd_issue @tdd_issue_11121 @mock_only Scenario: plan apply finds non-zero artifacts after re-invoked execute on execute/complete plan ``` --- Automated by CleverAgents Bot Supervisor: PR Review | Agent: pr-review-worker
Scenario: cleveragents/plan-<id> branch survives a second _create_sandbox_for_plan call when plan is execute/complete
Given a temp git repo with an execute-output branch for tdd 11121
And a mocked plan service with the plan in execute/complete state for tdd 11121
When I call _create_sandbox_for_plan a second time on the execute/complete plan for tdd 11121
Then the cleveragents plan branch should still exist after the second call for tdd 11121
@tdd_issue @tdd_issue_11121 @mock_only
Scenario: plan apply finds non-zero artifacts after re-invoked execute on execute/complete plan
Given a temp git repo with an execute-output branch for tdd 11121
And a mocked plan service with the plan in execute/complete state for tdd 11121
When I call _create_sandbox_for_plan a second time on the execute/complete plan for tdd 11121
Then the apply sandbox changes should find at least one artifact for tdd 11121
@tdd_issue @tdd_issue_11121 @mock_only
Scenario: cleveragents/plan-<id> branch survives a second _create_sandbox_for_plan call when plan is execute/processing
Given a temp git repo with an execute-output branch for tdd 11121
And a mocked plan service with the plan in execute/processing state for tdd 11121
When I call _create_sandbox_for_plan a second time on the execute/processing plan for tdd 11121
Then the cleveragents plan branch should still exist after the second call for tdd 11121
+2 -1
View File
@@ -83,9 +83,10 @@ Plan Generation Graph Builds Workflow With Correct Nodes
... assert 'analyze_requirements' in nodes
... assert 'generate_plan' in nodes
... assert 'validate' in nodes
... assert 'handle_retry' in nodes
... print(f'Graph has {len(nodes)} nodes')
${result}= Run Process ${PYTHON} -c ${script} shell=True
Should Contain ${result.stdout} Graph has 4 nodes
Should Contain ${result.stdout} Graph has 5 nodes
Should Be Equal As Integers ${result.rc} 0
LangGraph Graphs Package Exports Workflow Classes
@@ -154,6 +154,8 @@ class PlanGenerationGraph:
2. analyze_requirements: Analyzes user prompt for requirements
3. generate_plan: Generates code changes based on requirements
4. validate: Validates generated changes
5. handle_retry: Increments the retry counter (bridges conditional edge to
state update, since LangGraph conditional edges cannot persist mutations)
The workflow includes conditional edges for retry logic and checkpointing
for resumable execution.
@@ -273,6 +275,7 @@ class PlanGenerationGraph:
workflow.add_node("analyze_requirements", self._analyze_requirements)
workflow.add_node("generate_plan", self._generate_plan)
workflow.add_node("validate", self._validate)
workflow.add_node("handle_retry", self._handle_retry)
# Set entry point
workflow.set_entry_point("load_context")
@@ -282,12 +285,16 @@ class PlanGenerationGraph:
workflow.add_edge("analyze_requirements", "generate_plan")
workflow.add_edge("generate_plan", "validate")
# Route retries through handle_retry node to persist the retry_count
# increment (conditional edge functions cannot mutate state).
workflow.add_edge("handle_retry", "analyze_requirements")
# Add conditional edge for retry logic
workflow.add_conditional_edges(
"validate",
self._should_retry,
{
"retry": "analyze_requirements",
"retry": "handle_retry",
"end": END,
},
)
@@ -581,6 +588,24 @@ class PlanGenerationGraph:
return "end"
def _handle_retry(self, state: PlanGenerationState) -> dict[str, Any]:
"""Bridge node for the retry conditional edge.
Conditional edge functions in LangGraph cannot persist state
mutations this node exists solely to satisfy the graph
compiler requirement that the retry path goes through a node
(not directly from a conditional edge back to a node).
The retry counter is already incremented inside ``_validate``
(a node whose return dict IS merged into the state).
Args:
state: Current workflow state
Returns:
Empty update (no state mutation needed)
"""
return {}
def _format_context_summary(self, contexts: list[Context]) -> str:
"""Format context files into a summary string.
+13
View File
4
@@ -627,6 +627,19 @@ 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(), ".cleveragents", "sandbox")
os.makedirs(flat_root, exist_ok=True)
return flat_root, []
project_names = [pl.project_name for pl in getattr(plan, "project_links", [])]
sandboxes: list[_SandboxInfo] = []
Outdated
Review

Non-blocking observation — Option A vs Option B. Issue #11121 marked Option B as "preferred" (moving the _create_sandbox_for_plan() call to after the phase/state check in execute_plan() so it is only called when execution is actually needed). Option A (guard inside _create_sandbox_for_plan()) was implemented. Both are functionally correct.

If Option A is intentional, consider adding a sentence explaining the reasoning (e.g. defensive guard inside the function protects all callers, not just execute_plan). Not a blocker.


Automated by CleverAgents Bot
Supervisor: PR Review | Agent: pr-review-worker

**Non-blocking observation — Option A vs Option B.** Issue #11121 marked Option B as "preferred" (moving the `_create_sandbox_for_plan()` call to after the phase/state check in `execute_plan()` so it is only called when execution is actually needed). Option A (guard inside `_create_sandbox_for_plan()`) was implemented. Both are functionally correct. If Option A is intentional, consider adding a sentence explaining the reasoning (e.g. defensive guard inside the function protects all callers, not just `execute_plan`). Not a blocker. --- Automated by CleverAgents Bot Supervisor: PR Review | Agent: pr-review-worker