fix(cli): plan correct active-plan resolution in isolated environments #1184
@@ -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"
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user