fix(plan): restore _apply_output_dict and fix explain_decision_cmd plan_id fallback
CI / load-versions (pull_request) Successful in 9s
CI / push-validation (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 24s
CI / typecheck (pull_request) Successful in 1m10s
CI / build (pull_request) Successful in 3m21s
CI / helm (pull_request) Successful in 3m25s
CI / quality (pull_request) Successful in 3m43s
CI / security (pull_request) Successful in 4m33s
CI / unit_tests (pull_request) Failing after 5m24s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 8m29s
CI / status-check (pull_request) Failing after 1s

- Restore deleted _apply_output_dict function (104 lines) that builds the
  spec-required JSON envelope for plan apply output; used by robot integration
  tests via direct import in helper_plan_apply_json_envelope.py
- Restore lifecycle_apply_plan to use _apply_output_dict with full envelope
  format_output call for non-RICH output formats
- Remove re-introduced plan_id fallback from explain_decision_cmd that violated
  the explicit guard from issue #6325 (TDD regression test
  tdd_plan_explain_plan_id.feature); restore strict DecisionNotFoundError
  handling with typer.Exit(1)

ISSUES CLOSED: #6325
This commit is contained in:
2026-06-19 03:31:47 -04:00
parent 51cae21a97
commit 04263afbad
+123 -21
View File
@@ -477,6 +477,106 @@ def _execute_output_dict(
}
def _apply_output_dict(
plan: Any,
started_at: datetime | None = None,
duration_ms: int | None = None,
) -> dict[str, object]:
"""Build the spec-required JSON envelope for plan apply output."""
from cleveragents.domain.models.core.plan import Plan as LifecyclePlan
if not isinstance(plan, LifecyclePlan):
return {
"command": "plan apply",
"status": "ok",
"exit_code": 0,
"data": {"plan": str(plan)},
"timing": {},
"messages": ["Plan applied"],
}
plan_id = plan.identity.plan_id
if plan.is_terminal:
sandbox_cleanup: dict[str, object] = {
"worktree": "removed",
"branch": "merged to main",
"checkpoint": "archived",
}
else:
sandbox_cleanup = {
"worktree": "active",
"branch": "open",
"checkpoint": "pending",
}
validation: dict[str, object] = {
"tests": {"status": "passed", "passed": 0, "total": 0},
"lint": {"status": "passed", "warnings": 0},
"type_check": {"status": "passed", "errors": 0},
"duration_s": 0.0,
}
vs = getattr(plan, "validation_summary", None)
if vs: # pragma: no cover
for key in ("tests", "lint", "type_check"):
value = vs.get(key)
if value is not None:
validation[key] = value
total_cost = "$0.00"
if plan.cost_metadata and plan.cost_metadata.total_cost is not None:
total_cost = f"${plan.cost_metadata.total_cost:.2f}"
child_plan_count: int = len(getattr(plan, "child_plan_ids", None) or [])
lifecycle: dict[str, object] = {
"phase": plan.phase.value,
"state": plan.processing_state.value,
"total_duration": "0s",
"total_cost": total_cost,
"decisions_made": len(plan.decisions),
"child_plans": child_plan_count,
}
project_name: str | None = (
plan.project_links[0].project_name if plan.project_links else None
)
applied_at: str | None = (
plan.timestamps.applied_at.isoformat()
if plan.timestamps.applied_at is not None
else None
)
data: dict[str, object] = {
"plan_id": plan_id,
"artifacts": 0,
"changes": {"insertions": 0, "deletions": 0},
"project": project_name,
"applied_at": applied_at,
"validation": validation,
"sandbox_cleanup": sandbox_cleanup,
"lifecycle": lifecycle,
}
timing: dict[str, object] = {}
if applied_at is not None:
timing["started"] = applied_at
if started_at is not None: # pragma: no cover
timing["started"] = started_at.isoformat()
if duration_ms is not None: # pragma: no cover
timing["duration_ms"] = duration_ms
return {
"command": "plan apply",
"status": "ok",
"exit_code": 0,
"data": data,
"timing": timing,
"messages": ["Plan applied"],
}
def _get_current_project() -> Project:
"""Get the current project or exit with error.
@@ -2364,8 +2464,22 @@ def lifecycle_apply_plan(
plan = service._complete_apply_if_queued(plan_id)
if fmt != OutputFormat.RICH.value:
data = _plan_spec_dict(plan)
console.print(format_output(data, fmt))
envelope = _apply_output_dict(plan)
payload = cast("dict[str, Any]", envelope.get("data", {}))
messages = cast(
"list[str | dict[str, str]]",
envelope.get("messages", ["Plan applied"]),
)
console.print(
format_output(
payload,
fmt,
command="plan apply",
status="ok",
exit_code=0,
messages=messages,
)
)
else:
title = "Plan Applied" if plan.is_terminal else "Plan Applying"
_print_lifecycle_plan(plan, title=title)
@@ -4082,7 +4196,7 @@ def _build_explain_dict(
def explain_decision_cmd(
identifier: Annotated[
str,
typer.Argument(help="Decision or Plan ULID to explain"),
typer.Argument(help="Decision ULID to explain"),
],
fmt: Annotated[
str,
@@ -4097,7 +4211,7 @@ def explain_decision_cmd(
typer.Option("--show-reasoning", help="Include rationale and actor reasoning"),
] = False,
) -> None:
"""Explain a single decision or the root decision of a plan."""
"""Explain a single decision in a plan."""
from cleveragents.application.container import get_container
from cleveragents.application.services.decision_service import (
DecisionNotFoundError,
@@ -4106,24 +4220,12 @@ def explain_decision_cmd(
container = get_container()
svc = container.decision_service()
# First, try treating the identifier as a decision_id (backward compat).
decision = None
with suppress(DecisionNotFoundError):
# Look up the decision by its ULID.
try:
decision = svc.get_decision(identifier)
# 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)
except DecisionNotFoundError:
console.print(f"[red]Error:[/red] '{identifier}' not found as a decision.")
raise typer.Exit(1) from None
# Fetch total decision count for "X of Y" sequence format.
total_decisions: int | None = None