fix(cli): plan correct active-plan resolution in isolated environments #1184

Merged
brent.edwards merged 3 commits from fix/plan-correct-resolve into master 2026-03-31 01:49:16 +00:00
5 changed files with 314 additions and 1 deletions
+11 -1
View File
@@ -520,6 +520,17 @@ Feature: Consolidated Plan Misc
Then r2plan-a typer Abort should be raised
Scenario: r2plan resolve_active_plan_id skips home fallback when DB env override is set
When r2plan-I call _resolve_active_plan_id with explicit DB env override and no active plans
Then r2plan-a typer Abort should be raised
And r2plan-the home DB fallback should not be attempted
Scenario: r2plan resolve_active_plan_id surfaces unexpected fallback errors
When r2plan-I call _resolve_active_plan_id with unexpected fallback error
Then r2plan-a RuntimeError should be raised
# ============================================================
# Originally from: plan_cli_spec_print_r2.feature
# Feature: Plan CLI spec dict and print branch coverage (round 2)
@@ -1037,4 +1048,3 @@ Feature: Consolidated Plan Misc
And a PlanExecutor without execution_context
When I attempt to run execute with an empty plan_id
Then the plan execution context should raise a ValidationError containing "plan_id"
+107
View File
@@ -14,6 +14,8 @@ All step text uses the ``r2plan-`` prefix to avoid collisions.
from __future__ import annotations
import os
import tempfile
import warnings
from datetime import datetime
from types import SimpleNamespace
@@ -403,6 +405,97 @@ def step_resolve_service_error(context: Any) -> None:
context.r2_error = typer.Abort()
@when(
"r2plan-I call _resolve_active_plan_id with explicit DB env override and no active plans"
)
def step_resolve_skips_home_fallback_when_db_override(context: Any) -> None:
mock_svc = MagicMock()
terminal_plan = _make_plan(
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
mock_svc.list_plans.return_value = [terminal_plan]
with tempfile.TemporaryDirectory(prefix="r2plan-db-override-") as tmp:
home_dir = os.path.join(tmp, "home")
os.makedirs(home_dir, exist_ok=True)
mock_uow = MagicMock()
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
),
patch(
"cleveragents.infrastructure.database.unit_of_work.UnitOfWork",
return_value=mock_uow,
),
patch.dict(
os.environ,
{
"CLEVERAGENTS_HOME": home_dir,
"CLEVERAGENTS_DATABASE_URL": "sqlite:////tmp/explicit-test.db",
},
clear=False,
),
):
try:
_resolve_active_plan_id()
context.r2_error = None
except typer.Abort:
context.r2_error = typer.Abort()
context.r2_home_fallback_attempted = mock_uow.called
@when("r2plan-I call _resolve_active_plan_id with unexpected fallback error")
def step_resolve_unexpected_fallback_error(context: Any) -> None:
mock_svc = MagicMock()
terminal_plan = _make_plan(
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
)
mock_svc.list_plans.return_value = [terminal_plan]
with tempfile.TemporaryDirectory(prefix="r2plan-fallback-error-") as tmp:
home_dir = os.path.join(tmp, "home")
workspace_dir = os.path.join(tmp, "workspace")
os.makedirs(home_dir, exist_ok=True)
os.makedirs(workspace_dir, exist_ok=True)
original_cwd = os.getcwd()
os.chdir(workspace_dir)
try:
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
),
patch(
"cleveragents.infrastructure.database.unit_of_work.UnitOfWork",
side_effect=RuntimeError("unexpected fallback crash"),
),
patch.dict(
os.environ,
{
"CLEVERAGENTS_HOME": home_dir,
"CLEVERAGENTS_DATABASE_URL": "",
"CLEVERAGENTS_TEST_DATABASE_URL": "",
},
clear=False,
),
):
try:
_resolve_active_plan_id()
context.r2_error = None
except typer.Abort:
context.r2_error = typer.Abort()
except RuntimeError as exc:
context.r2_error = exc
finally:
os.chdir(original_cwd)
# ---------------------------------------------------------------------------
# Then steps - legacy wrapper assertions
# ---------------------------------------------------------------------------
@@ -440,3 +533,17 @@ def step_typer_abort(context: Any) -> None:
assert isinstance(context.r2_error, typer.Abort), (
f"Expected typer.Abort, got {type(context.r2_error).__name__}"
)
@then("r2plan-the home DB fallback should not be attempted")
def step_home_fallback_not_attempted(context: Any) -> None:
attempted = getattr(context, "r2_home_fallback_attempted", None)
assert attempted is False, "Expected fallback UnitOfWork not to be called"
@then("r2plan-a RuntimeError should be raised")
def step_runtime_error(context: Any) -> None:
assert context.r2_error is not None, "Expected RuntimeError but none raised"
assert isinstance(context.r2_error, RuntimeError), (
f"Expected RuntimeError, got {type(context.r2_error).__name__}"
)
@@ -0,0 +1,126 @@
"""Regression helper for issue #1025 (plan correct auto-resolve).
This helper simulates isolated CLI invocations that share
``CLEVERAGENTS_HOME`` but run from different working directories.
It verifies that ``agents plan correct <decision_id>`` can auto-resolve
the active plan (without ``--plan``) after process-global state is reset.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
from typer.testing import CliRunner
from ulid import ULID
from cleveragents.application.container import get_container, reset_container
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState
def _reset_process_state() -> None:
"""Reset singleton process state to emulate a fresh subprocess."""
Settings.reset()
reset_container()
def _seed_active_plan(home_dir: Path) -> str:
"""Create and persist an Execute/COMPLETE plan under ``home_dir`` DB."""
os.chdir(home_dir)
_reset_process_state()
container = get_container()
lifecycle = container.plan_lifecycle_service()
action_name = f"local/issue-1025-{ULID()}"
lifecycle.create_action(
name=action_name,
description="Issue #1025 regression action",
definition_of_done="Do the thing",
strategy_actor="local/strategy-actor",
execution_actor="local/execution-actor",
)
plan = lifecycle.use_action(action_name=action_name)
plan.phase = PlanPhase.EXECUTE
plan.processing_state = ProcessingState.COMPLETE
lifecycle.save_plan(plan)
return plan.identity.plan_id
def run_regression() -> int:
"""Run the regression scenario and return process exit status."""
with tempfile.TemporaryDirectory(prefix="ca_issue_1025_") as tmp:
root = Path(tmp)
home_dir = root / "home"
workspace_dir = root / "workspace"
home_dir.mkdir(parents=True, exist_ok=True)
workspace_dir.mkdir(parents=True, exist_ok=True)
os.environ["CLEVERAGENTS_HOME"] = str(home_dir)
os.environ["CLEVERAGENTS_AUTO_APPLY_MIGRATIONS"] = "true"
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)
os.environ.pop("CLEVERAGENTS_TEST_DATABASE_URL", None)
plan_id = _seed_active_plan(home_dir)
# Simulate a new isolated invocation from a *different* CWD.
os.chdir(workspace_dir)
_reset_process_state()
fake_svc = MagicMock()
fake_svc.request_correction.return_value = SimpleNamespace(
correction_id=str(ULID()),
mode=SimpleNamespace(value="revert"),
target_decision_id=str(ULID()),
guidance="regression guidance",
)
fake_svc.execute_correction.return_value = SimpleNamespace(
correction_id=str(ULID()),
status=SimpleNamespace(value="applied"),
new_decisions=[],
reverted_decisions=[],
)
runner = CliRunner()
container = get_container()
with container.correction_service.override(fake_svc):
result = runner.invoke(
plan_app,
[
"correct",
str(ULID()),
"--mode",
"revert",
"--guidance",
"regression guidance",
"--yes",
],
)
if result.exit_code != 0:
print(result.output)
return 1
request_call = fake_svc.request_correction.call_args
if request_call is None:
print("request_correction was not invoked")
return 1
resolved_plan_id = request_call.kwargs.get("plan_id")
if resolved_plan_id != plan_id:
print(f"Resolved plan mismatch: expected {plan_id}, got {resolved_plan_id}")
return 1
print("issue-1025-plan-correct-isolated-resolve-ok")
return 0
if __name__ == "__main__":
raise SystemExit(run_regression())
+19
View File
@@ -0,0 +1,19 @@
*** Settings ***
Documentation Regression test for issue #1025 (plan correct auto-resolve)
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_plan_correct_isolated_resolve.py
*** Test Cases ***
Plan Correct Auto Resolves Active Plan In Isolated Environment
[Documentation] Verify ``plan correct`` resolves active plan without ``--plan``
... when subprocess-like invocations share CLEVERAGENTS_HOME but
... run from different working directories.
${result}= Run Process ${PYTHON} ${HELPER} cwd=${WORKSPACE} timeout=120s on_timeout=kill
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} issue-1025-plan-correct-isolated-resolve-ok
+51
View File
@@ -35,6 +35,7 @@ import os
import re
import warnings
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any
import typer
@@ -42,6 +43,7 @@ from rich.console import Console
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.table import Table
from sqlalchemy.exc import SQLAlchemyError
from cleveragents.a2a.models import A2aRequest
from cleveragents.cli.formatting import OutputFormat, format_output
@@ -3152,11 +3154,60 @@ def _resolve_active_plan_id() -> str:
Raises:
typer.Abort: If no suitable plan is found.
"""
def _resolve_from_cleveragents_home() -> str | None:
"""Best-effort fallback lookup using ``CLEVERAGENTS_HOME`` storage.
In isolated subprocess environments some commands run from a project
workspace while persistent state lives under ``CLEVERAGENTS_HOME``.
When the default DB lookup returns no active plans, this fallback
attempts to read lifecycle plans from ``$CLEVERAGENTS_HOME``.
"""
explicit_db_override = any(
os.environ.get(key, "").strip()
for key in (
"CLEVERAGENTS_DATABASE_URL",
"CLEVERAGENTS_TEST_DATABASE_URL",
)
)
if explicit_db_override:
return None
home_raw = os.environ.get("CLEVERAGENTS_HOME", "").strip()
if not home_raw:
return None
home_db = (Path(home_raw).expanduser() / ".cleveragents" / "db.sqlite").resolve(
strict=False
)
current_db = (Path.cwd() / ".cleveragents" / "db.sqlite").resolve(strict=False)
if home_db == current_db:
return None
try:
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
home_url = f"sqlite:///{home_db}"
uow = UnitOfWork(home_url, require_confirmation=False)
with uow.transaction() as ctx:
plans = ctx.lifecycle_plans.list_all()
active = [p for p in plans if not p.is_terminal]
if not active:
return None
return active[0].identity.plan_id
except (CleverAgentsError, OSError, SQLAlchemyError, ValueError):
return None
try:
service = _get_lifecycle_service()
plans = service.list_plans()
active = [p for p in plans if not p.is_terminal]
if not active:
fallback_plan_id = _resolve_from_cleveragents_home()
if fallback_plan_id:
return fallback_plan_id
console.print(
"[red]Error:[/red] No active plan found. "
"Specify --plan <PLAN_ID> explicitly."