"""Plan executor service: stub actors for Strategize and Execute phases.

Provides local-only (no LLM) stub actors for M1 that integrate with the
``PlanLifecycleService`` to drive plans through the Strategize and Execute
phases.  When a ``PlanExecutionContext`` is provided, the execute phase
delegates to ``RuntimeExecuteActor`` for full changeset capture.

Updated in M4 to add optional checkpoint hooks via ``CheckpointManager``.
Updated in M5 to wire ``SubplanService`` and ``SubplanExecutionService``
into the Execute phase so that ``subplan_spawn`` and
``subplan_parallel_spawn`` decisions are realised as actual child plan
executions.
"""

from __future__ import annotations

import time
import traceback
from collections.abc import Callable
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

import structlog
from pydantic import BaseModel, ConfigDict, Field
from ulid import ULID

from cleveragents.application.services.autonomy_guardrail_service import (
    AutonomyGuardrailService,
)
from cleveragents.application.services.fix_then_revalidate import (
    FixThenRevalidateOrchestrator,
)
from cleveragents.application.services.plan_execution_context import (
    PlanExecutionContext,
    RuntimeExecuteActor,
    RuntimeExecuteResult,
)
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core.change import ChangeSetStore
from cleveragents.domain.models.core.estimation import EstimationResult
from cleveragents.domain.models.core.plan import (
    PlanInvariant,
    PlanPhase,
    ProcessingState,
    SubplanConfig,
)
from cleveragents.domain.models.observability.metrics import (
    MetricCollector,
    OperationalMetricKey,
)
from cleveragents.infrastructure.sandbox.checkpoint import (
    CheckpointManager,
    SandboxCheckpoint,
)
from cleveragents.tool.builtins.changeset import ChangeSet, ChangeSetCapture
from cleveragents.tool.runner import ToolRunner

if TYPE_CHECKING:
    from cleveragents.application.services.error_recovery_service import (
        ErrorRecoveryService,
    )
    from cleveragents.application.services.subplan_execution_service import (
        SubplanExecutionResult,
        SubplanExecutionService,
    )
    from cleveragents.application.services.subplan_service import (
        SpawnResult,
        SubplanService,
    )
    from cleveragents.infrastructure.observability.metrics_emitter import (
        MetricsEmitter,
    )

logger = structlog.get_logger(__name__)

StreamCallback = Callable[[str, dict[str, Any]], None]


# ---------------------------------------------------------------------------
# Result models
# ---------------------------------------------------------------------------


class StrategyDecision(BaseModel):
    """A single decision node in the strategy decision tree."""

    decision_id: str = Field(..., description="ULID for this decision node")
    step_text: str = Field(..., description="The step description text")
    sequence: int = Field(..., ge=0, description="Order in the decision tree")
    parent_id: str | None = Field(default=None, description="Parent decision ULID")

    model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)


class StrategizeResult(BaseModel):
    """Output from the Strategize stub actor."""

    decision_root_id: str = Field(..., description="ULID of root decision node")
    decisions: list[StrategyDecision] = Field(default_factory=list)
    invariant_records: list[dict[str, Any]] = Field(default_factory=list)

    model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)


class ExecuteResult(BaseModel):
    """Output from the Execute stub actor."""

    changeset_id: str = Field(..., description="ID of the produced ChangeSet")
    changeset: ChangeSet = Field(..., description="The captured ChangeSet")
    tool_calls_count: int = Field(default=0, ge=0)
    sandbox_refs: list[str] = Field(default_factory=list)

    model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)


# ---------------------------------------------------------------------------
# Stub actors
# ---------------------------------------------------------------------------


class StrategizeStubActor:
    """Local-only strategize actor producing a decision tree from definition_of_done."""

    def execute(
        self,
        plan_id: str,
        definition_of_done: str | None,
        invariants: list[PlanInvariant] | None = None,
        stream_callback: StreamCallback | None = None,
    ) -> StrategizeResult:
        if not plan_id:
            raise ValidationError("plan_id must not be empty")
        if stream_callback is not None:
            stream_callback(
                "strategize_started", {"plan_id": plan_id, "phase": "strategize"}
            )

        steps = self._parse_steps(definition_of_done or "")
        root_id = str(ULID())
        decisions: list[StrategyDecision] = []
        for idx, step_text in enumerate(steps):
            decisions.append(
                StrategyDecision(
                    decision_id=str(ULID()) if idx > 0 else root_id,
                    step_text=step_text,
                    sequence=idx,
                    parent_id=root_id if idx > 0 else None,
                )
            )

        invariant_records: list[dict[str, Any]] = []
        for inv in invariants or []:
            invariant_records.append(
                {
                    "text": inv.text,
                    "source": inv.source.value,
                    "enforced": True,
                    "enforcement_note": "stub: accepted without reconciliation",
                }
            )

        if stream_callback is not None:
            stream_callback(
                "strategize_decisions",
                {
                    "plan_id": plan_id,
                    "decision_count": len(decisions),
                    "root_id": root_id,
                },
            )

        result = StrategizeResult(
            decision_root_id=root_id,
            decisions=decisions,
            invariant_records=invariant_records,
        )
        if stream_callback is not None:
            stream_callback(
                "strategize_complete",
                {"plan_id": plan_id, "decision_count": len(decisions)},
            )
        return result

    @staticmethod
    def _parse_steps(definition_of_done: str) -> list[str]:
        """Parse definition of done into discrete steps."""
        if not definition_of_done.strip():
            return ["Complete the plan objectives"]
        lines = definition_of_done.strip().splitlines()
        steps: list[str] = []
        for line in lines:
            cleaned = line.strip()
            if not cleaned:
                continue
            for prefix in ("-", "*", "•"):
                if cleaned.startswith(prefix):
                    cleaned = cleaned[len(prefix) :].strip()
                    break
            if len(cleaned) > 2 and cleaned[0].isdigit():
                rest = cleaned.lstrip("0123456789")
                if rest and rest[0] in (".", ")"):
                    cleaned = rest[1:].strip()
            if cleaned:
                steps.append(cleaned)
        return steps if steps else ["Complete the plan objectives"]


class ExecuteStubActor:
    """Local-only execute actor routing tool calls through ChangeSetCapture."""

    def execute(
        self,
        plan_id: str,
        decisions: list[StrategyDecision],
        tool_runner: ToolRunner | None = None,
        sandbox_root: str | None = None,
        stream_callback: StreamCallback | None = None,
        *,
        read_only: bool = False,
    ) -> ExecuteResult:
        if not plan_id:
            raise ValidationError("plan_id must not be empty")
        if stream_callback is not None:
            stream_callback("execute_started", {"plan_id": plan_id, "phase": "execute"})

        changeset_id = str(ULID())
        capture = ChangeSetCapture(
            plan_id=plan_id,
            resource_id=changeset_id,
            sandbox_root=sandbox_root,
            read_only=read_only,
        )
        tool_calls_count = 0
        sandbox_refs: list[str] = []
        if sandbox_root is not None:
            sandbox_refs.append(sandbox_root)

        for decision in decisions:
            if stream_callback is not None:
                stream_callback(
                    "execute_step",
                    {
                        "plan_id": plan_id,
                        "decision_id": decision.decision_id,
                        "step": decision.step_text,
                        "sequence": decision.sequence,
                    },
                )
            if tool_runner is not None:
                tool_calls_count += len(tool_runner.discover())

        changeset = capture.get_changeset()
        if stream_callback is not None:
            stream_callback(
                "execute_complete",
                {
                    "plan_id": plan_id,
                    "changeset_id": changeset_id,
                    "tool_calls_count": tool_calls_count,
                },
            )
        return ExecuteResult(
            changeset_id=changeset_id,
            changeset=changeset,
            tool_calls_count=tool_calls_count,
            sandbox_refs=sandbox_refs,
        )


class EstimationStubActor:
    """Local estimation stub that produces a placeholder estimate.

    Used when no real estimation actor is configured.  Returns an
    ``EstimationResult`` with a summary indicating that estimation was
    skipped.
    """

    def estimate(self, plan_id: str) -> EstimationResult:
        """Produce a placeholder estimation result.

        Args:
            plan_id: The plan ULID (used for logging/tracing).

        Returns:
            An ``EstimationResult`` with a stub summary.
        """
        if not plan_id:
            raise ValidationError("plan_id must not be empty")
        return EstimationResult(
            summary="Estimation skipped (stub actor)",
        )


# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------


class PlanExecutor:
    """Orchestrates strategize and execute phases for a plan.

    When ``execution_context`` is provided, execute dispatches through
    ``RuntimeExecuteActor``.  Otherwise falls back to ``ExecuteStubActor``.
    """

    def __init__(
        self,
        lifecycle_service: Any,
        tool_runner: ToolRunner | None = None,
        sandbox_root: str | None = None,
        execution_context: PlanExecutionContext | None = None,
        error_recovery_service: ErrorRecoveryService | None = None,
        checkpoint_manager: CheckpointManager | None = None,
        guardrail_service: AutonomyGuardrailService | None = None,
        metrics_emitter: MetricsEmitter | None = None,
        strategize_actor: Any | None = None,
        execute_actor: Any | None = None,
        fix_revalidate_orchestrator: FixThenRevalidateOrchestrator | None = None,
        subplan_service: SubplanService | None = None,
        subplan_execution_service: SubplanExecutionService | None = None,
    ) -> None:
        """Initialize the plan executor.

        Args:
            lifecycle_service: The ``PlanLifecycleService`` instance.
            tool_runner: Optional ``ToolRunner`` for execute phase.
            sandbox_root: Optional sandbox root directory.
            execution_context: Optional execution context for runtime mode.
            error_recovery_service: Optional error recovery service for
                recording errors and managing retry logic.
            checkpoint_manager: Optional checkpoint manager for
                sandbox state snapshots during execute.  When ``None``,
                checkpoint hooks are silently skipped.
            guardrail_service: Optional autonomy guardrail service for
                enforcing step limits, budgets, and wall-clock time.
            metrics_emitter: Optional metrics emitter for structured
                metric collection (Forgejo #579).
            strategize_actor: Optional custom strategize actor.  When
                ``None``, the default ``StrategizeStubActor`` is used.
                Pass an ``LLMStrategizeActor`` for real LLM execution.
            execute_actor: Optional custom execute actor.  When ``None``,
                the default ``ExecuteStubActor`` is used.  Pass an
                ``LLMExecuteActor`` for real LLM execution.
            fix_revalidate_orchestrator: Optional orchestrator for
                fix-then-revalidate loops when required validations
                fail during execution (Forgejo #583).
            subplan_service: Optional service for spawning child plans
                from ``subplan_spawn`` / ``subplan_parallel_spawn``
                decisions recorded during Strategize.  When ``None``,
                subplan spawning is skipped (no-op).
            subplan_execution_service: Optional service for executing
                spawned child plans.  When ``None``, child plan
                execution is skipped even if subplans were spawned.
        """
        if lifecycle_service is None:
            raise ValidationError("lifecycle_service must not be None")
        self._lifecycle = lifecycle_service
        self._tool_runner = tool_runner
        self._sandbox_root = sandbox_root
        self._execution_context = execution_context
        self._error_recovery = error_recovery_service
        self._checkpoint_manager = checkpoint_manager
        self._guardrail_service = guardrail_service
        self._metrics_emitter = metrics_emitter
        self._fix_revalidate_orchestrator = fix_revalidate_orchestrator
        self._subplan_service = subplan_service
        self._subplan_execution_service = subplan_execution_service
        self._strategize_actor = strategize_actor or StrategizeStubActor()
        self._execute_actor = execute_actor or ExecuteStubActor()
        self._logger = logger.bind(service="plan_executor")

    def _try_emit_metric(
        self,
        key: OperationalMetricKey,
        plan_id: str,
        value: float,
    ) -> None:
        """Create and emit a metric, ignoring validation/emission errors.

        Best-effort wrapper that tolerates invalid plan IDs (e.g. in tests)
        and any other construction or emission failures.
        """
        if self._metrics_emitter is None:
            return
        try:
            entry = MetricCollector.record(key, value, plan_id)
            self._metrics_emitter.emit(entry)
        except Exception:
            self._logger.debug(
                "Metric creation/emission failed (non-fatal)",
                metric_key=key,
                exc_info=True,
            )

    @property
    def has_runtime(self) -> bool:
        """True if an execution context is configured for runtime mode."""
        return self._execution_context is not None

    @property
    def changeset_store(self) -> ChangeSetStore | None:
        """Return the changeset store from the execution context, if any."""
        if self._execution_context is not None:
            return self._execution_context.changeset_store
        return None

    @property
    def checkpoint_manager(self) -> CheckpointManager | None:
        """Return the checkpoint manager, if configured."""
        return self._checkpoint_manager

    @property
    def execution_context(self) -> PlanExecutionContext | None:
        """Return the execution context, if configured."""
        return self._execution_context

    @property
    def fix_revalidate_orchestrator(
        self,
    ) -> FixThenRevalidateOrchestrator | None:
        """Return the fix-then-revalidate orchestrator, if configured."""
        return self._fix_revalidate_orchestrator

    @property
    def subplan_service(self) -> SubplanService | None:
        """Return the subplan service, if configured."""
        return self._subplan_service

    @property
    def subplan_execution_service(self) -> SubplanExecutionService | None:
        """Return the subplan execution service, if configured."""
        return self._subplan_execution_service

    # ------------------------------------------------------------------
    # Subplan spawning helpers
    # ------------------------------------------------------------------

    def _spawn_subplans(self, plan: Any) -> SpawnResult | None:
        """Query spawn decisions and call SubplanService.spawn() for each.

        Retrieves all ``subplan_spawn`` and ``subplan_parallel_spawn``
        decisions recorded during the Strategize phase and delegates to
        :class:`SubplanService` to build child plan objects and status
        entries on the parent plan.

        Args:
            plan: The parent plan domain object.

        Returns:
            A :class:`SpawnResult` if subplans were spawned, or ``None``
            when no spawn decisions exist or no service is configured.
        """
        if self._subplan_service is None:
            return None

        plan_id: str = plan.identity.plan_id
        spawn_decisions = self._subplan_service.get_spawn_decisions(plan_id)
        if not spawn_decisions:
            self._logger.debug(
                "No spawn decisions found — skipping subplan spawning",
                plan_id=plan_id,
            )
            return None

        spawn_entries = self._subplan_service.build_spawn_entries(spawn_decisions)
        config: SubplanConfig = plan.subplan_config or SubplanConfig()

        spawn_result = self._subplan_service.spawn(
            parent_plan=plan,
            config=config,
            spawn_entries=spawn_entries,
        )
        self._logger.info(
            "Subplans spawned",
            plan_id=plan_id,
            count=spawn_result.total_spawned,
            execution_mode=spawn_result.execution_mode,
        )
        return spawn_result

    def _execute_subplans(
        self,
        plan: Any,
        spawn_result: SpawnResult,
    ) -> SubplanExecutionResult | None:
        """Execute spawned child plans via SubplanExecutionService.

        Calls :meth:`SubplanExecutionService.execute_all` with the
        statuses produced by :meth:`_spawn_subplans`, handling both
        sequential and parallel execution groups as configured on the
        parent plan's :class:`SubplanConfig`.

        Args:
            plan: The parent plan domain object.
            spawn_result: The result returned by :meth:`_spawn_subplans`.

        Returns:
            A :class:`SubplanExecutionResult` if execution was attempted,
            or ``None`` when no execution service is configured.
        """
        if self._subplan_execution_service is None:
            return None

        plan_id: str = plan.identity.plan_id
        statuses = spawn_result.spawned_statuses
        if not statuses:
            return None

        exec_result = self._subplan_execution_service.execute_all(
            subplan_statuses=statuses,
            base_files={},
        )
        self._logger.info(
            "Subplan execution complete",
            plan_id=plan_id,
            all_succeeded=exec_result.all_succeeded,
            failed_ids=exec_result.failed_subplan_ids,
            duration_ms=exec_result.total_duration_ms,
        )
        return exec_result

    def _apply_subplan_results_to_plan(
        self,
        plan: Any,
        spawn_result: SpawnResult,
        exec_result: SubplanExecutionResult | None,
    ) -> None:
        """Update parent plan status based on child subplan outcomes.

        Merges the updated :class:`SubplanStatus` objects from
        *exec_result* back onto the parent plan so that the lifecycle
        service persists accurate child-plan state.  When any child
        plan failed, the parent plan's ``error_details`` is annotated
        with the list of failed subplan IDs.

        Args:
            plan: The parent plan domain object (mutated in-place).
            spawn_result: The spawn result containing initial statuses.
            exec_result: The execution result, or ``None`` if execution
                was not attempted.
        """
        if exec_result is None:
            # Execution service not configured — keep spawned statuses as-is
            plan.subplan_statuses = list(spawn_result.spawned_statuses)
            return

        # Replace statuses with the updated ones from execution
        plan.subplan_statuses = list(exec_result.statuses)

        if not exec_result.all_succeeded:
            failed = exec_result.failed_subplan_ids
            existing = dict(plan.error_details or {})
            existing["failed_subplan_ids"] = ",".join(failed)
            existing["subplan_execution_failed"] = "true"
            plan.error_details = existing
            self._logger.warning(
                "One or more subplans failed",
                failed_subplan_ids=failed,
                plan_id=plan.identity.plan_id,
            )

    def _is_auto_trigger_active(self, trigger: str) -> bool:
        """Return True if the given automatic checkpoint trigger is active.

        Delegates to ``ToolRunner.is_trigger_active()`` when a runner is
        configured, otherwise defaults to all triggers being active.

        Args:
            trigger: Trigger name (e.g. ``"on_error"``).

        Returns:
            ``True`` if the trigger should fire.
        """
        if self._tool_runner is not None:
            return self._tool_runner.is_trigger_active(trigger)
        # Default: all triggers active when no runner is configured
        return True

    def _try_create_checkpoint(
        self,
        plan_id: str,
        phase: str,
        metadata: dict[str, Any] | None = None,
    ) -> SandboxCheckpoint | None:
        """Create a checkpoint if a manager and sandbox root are available.

        Silently returns ``None`` when no checkpoint manager is configured
        or when checkpoint creation fails (non-fatal).

        Args:
            plan_id: The plan identifier.
            phase: Checkpoint phase label (e.g. ``"pre_execute"``).
            metadata: Optional metadata to attach.

        Returns:
            The created checkpoint, or ``None``.
        """
        if self._checkpoint_manager is None:
            return None

        sandbox = self._resolve_sandbox_for_checkpoint(plan_id)
        if sandbox is None:
            return None

        try:
            meta = dict(metadata or {})
            if sandbox.context is not None:
                meta["sandbox_path"] = sandbox.context.sandbox_path
            return self._checkpoint_manager.create_checkpoint(
                sandbox=sandbox,
                plan_id=plan_id,
                phase=phase,
                metadata=meta,
            )
        except Exception:
            self._logger.debug(
                "Checkpoint creation failed (non-fatal)",
                plan_id=plan_id,
                phase=phase,
                exc_info=True,
            )
            return None

    def _try_rollback_to_last_checkpoint(self, plan_id: str) -> bool:
        """Attempt rollback to the most recent checkpoint for a plan.

        Silently returns ``False`` when no checkpoint manager is
        configured, no checkpoints exist, or rollback fails.

        Args:
            plan_id: The plan identifier.

        Returns:
            ``True`` if rollback succeeded.
        """
        if self._checkpoint_manager is None:
            return False

        sandbox = self._resolve_sandbox_for_checkpoint(plan_id)
        if sandbox is None:
            return False

        checkpoints = self._checkpoint_manager.list_checkpoints(sandbox.sandbox_id)
        if not checkpoints:
            return False

        last_cp = checkpoints[-1]
        try:
            return self._checkpoint_manager.rollback_to(last_cp)
        except Exception:
            self._logger.debug(
                "Checkpoint rollback failed (non-fatal)",
                plan_id=plan_id,
                checkpoint_id=last_cp.checkpoint_id,
                exc_info=True,
            )
            return False

    def _resolve_sandbox_for_checkpoint(self, plan_id: str) -> Any:
        """Resolve a sandbox object suitable for checkpointing.

        Returns a lightweight mock-like object with ``sandbox_id`` and
        ``context`` when only a ``sandbox_root`` is configured (no
        execution context).

        Args:
            plan_id: The plan identifier.

        Returns:
            A sandbox-like object, or ``None``.
        """
        if self._execution_context is not None:
            sandbox_mgr = getattr(self._execution_context, "sandbox_manager", None)
            if sandbox_mgr is not None:
                sandboxes = sandbox_mgr.list_sandboxes(plan_id)
                if sandboxes:
                    return sandboxes[0]

        if self._sandbox_root is not None:
            return _SandboxRootProxy(self._sandbox_root, plan_id)

        return None

    def run_strategize(
        self,
        plan_id: str,
        stream_callback: StreamCallback | None = None,
    ) -> StrategizeResult:
        """Run the strategize phase for a plan."""
        if not plan_id:
            raise ValidationError("plan_id must not be empty")

        plan = self._lifecycle.get_plan(plan_id)
        if plan.phase != PlanPhase.STRATEGIZE:
            raise PlanError(
                f"Plan {plan_id} is not in Strategize phase "
                f"(current: {plan.phase.value})"
            )

        self._lifecycle.start_strategize(plan_id)
        try:
            result = self._strategize_actor.execute(
                plan_id=plan_id,
                definition_of_done=plan.definition_of_done,
                invariants=plan.invariants,
                stream_callback=stream_callback,
            )
            plan = self._lifecycle.get_plan(plan_id)
            plan.decision_root_id = result.decision_root_id
            plan.timestamps.updated_at = datetime.now(tz=UTC)

            # Store decisions and invariant records in error_details as
            # structured metadata (Plan model uses error_details for
            # arbitrary metadata storage until a dedicated field lands)
            plan.error_details = {
                "strategy_decisions": str(len(result.decisions)),
                "invariant_records": str(len(result.invariant_records)),
            }
            self._lifecycle._commit_plan(plan)
            if self._execution_context is not None:
                self._execution_context.decision_root_id = result.decision_root_id
            self._lifecycle.complete_strategize(plan_id)
            self._try_emit_metric(
                OperationalMetricKey.PLAN_DECISION_COUNT,
                plan_id,
                float(len(result.decisions)),
            )
            self._logger.info(
                "Strategize completed",
                plan_id=plan_id,
                decision_count=len(result.decisions),
                root_id=result.decision_root_id,
            )
            return result
        except Exception as exc:
            # Record via error recovery service if available
            if self._error_recovery is not None:
                self._error_recovery.record_error(
                    plan_id=plan_id,
                    phase="strategize",
                    message=str(exc),
                    exception=exc,
                    exception_type=type(exc).__name__,
                )

            error_msg = f"{type(exc).__name__}: {exc}"
            plan = self._lifecycle.get_plan(plan_id)
            plan.error_details = {
                "exception_type": type(exc).__name__,
                "traceback": traceback.format_exc(),
            }
            self._lifecycle._commit_plan(plan)
            self._lifecycle.fail_strategize(plan_id, error_msg)
            raise

    def run_execute(
        self,
        plan_id: str,
        stream_callback: StreamCallback | None = None,
    ) -> ExecuteResult | RuntimeExecuteResult:
        """Run execute phase — runtime mode if context set, else stub."""
        if not plan_id:
            raise ValidationError("plan_id must not be empty")
        if self._execution_context is not None:
            return self._run_execute_with_runtime(plan_id, stream_callback)
        return self._run_execute_with_stub(plan_id, stream_callback)

    def _build_decisions(self, plan: Any) -> list[StrategyDecision]:
        """Build decisions from plan definition_of_done."""
        steps = StrategizeStubActor._parse_steps(plan.definition_of_done or "")
        return [
            StrategyDecision(
                decision_id=plan.decision_root_id if idx == 0 else str(ULID()),
                step_text=step,
                sequence=idx,
                parent_id=plan.decision_root_id if idx > 0 else None,
            )
            for idx, step in enumerate(steps)
        ]

    def _guard_execute(self, plan_id: str) -> Any:
        """Validate execute-phase guards and return the plan."""
        plan = self._lifecycle.get_plan(plan_id)
        if plan.phase != PlanPhase.EXECUTE:
            raise PlanError(
                f"Plan {plan_id} is not in Execute phase (current: {plan.phase.value})"
            )
        if plan.state != ProcessingState.QUEUED:
            raise PlanError(
                f"Plan {plan_id} is not queued for execution (current: {plan.state})"
            )
        if plan.decision_root_id is None:
            raise PlanError(
                f"Plan {plan_id} has no decision tree. "
                "Strategize must complete before Execute."
            )
        self._enforce_guardrails(plan_id)
        return plan

    def _enforce_guardrails(self, plan_id: str) -> None:
        """Pre-flight guardrail checks before execution begins.

        Marks the execution start time (for wall-clock tracking) if
        not already set, then checks wall-clock time and current step
        limit.  Raises ``PlanError`` if any guardrail blocks execution.
        """
        if self._guardrail_service is None:
            return
        guardrails = self._guardrail_service.get_guardrails(plan_id)
        if guardrails is not None and guardrails.start_time is None:
            guardrails.mark_started()
        if not self._guardrail_service.check_wall_clock(plan_id):
            raise PlanError(f"Guardrail wall-clock limit exceeded for plan {plan_id}")
        if guardrails is not None:
            current_step = guardrails.step_count
            if not self._guardrail_service.check_step_limit(plan_id, current_step):
                raise PlanError(
                    f"Guardrail step limit already reached for plan {plan_id}"
                )

    def _enforce_guardrails_per_step(self, plan_id: str) -> None:
        """Per-decision guardrail enforcement.

        Increments the step counter and checks step limit and
        wall-clock time. Raises ``PlanError`` if blocked.
        """
        if self._guardrail_service is None:
            return
        guardrails = self._guardrail_service.get_guardrails(plan_id)
        if guardrails is None:
            return  # pragma: no cover - defensive
        next_step = guardrails.step_count + 1
        if not self._guardrail_service.check_step_limit(plan_id, next_step):
            raise PlanError(
                f"Guardrail step limit reached for plan {plan_id} at step {next_step}"
            )
        if not self._guardrail_service.check_wall_clock(plan_id):
            raise PlanError(f"Guardrail wall-clock limit exceeded for plan {plan_id}")

    def _run_execute_with_runtime(
        self,
        plan_id: str,
        stream_callback: StreamCallback | None = None,
    ) -> RuntimeExecuteResult:
        """Execute using RuntimeExecuteActor with changeset capture."""
        plan = self._guard_execute(plan_id)
        decisions = self._build_decisions(plan)

        if self._tool_runner is None:
            raise PlanError("Runtime execute mode requires a ToolRunner.")

        assert self._execution_context is not None  # guarded by caller
        runtime_actor = RuntimeExecuteActor(
            tool_runner=self._tool_runner,
            execution_context=self._execution_context,
        )
        self._lifecycle.start_execute(plan_id)
        self._try_create_checkpoint(plan_id, "pre_execute")
        _start_ns = time.monotonic_ns()
        try:
            # Enforce per-step guardrails for each decision
            for _decision in decisions:
                self._enforce_guardrails_per_step(plan_id)

            result = runtime_actor.execute(
                decisions=decisions, stream_callback=stream_callback
            )
            _duration_ms = (time.monotonic_ns() - _start_ns) / 1_000_000
            plan = self._lifecycle.get_plan(plan_id)
            plan.changeset_id = result.changeset_id
            plan.sandbox_refs = result.sandbox_refs
            plan.error_details = {
                "tool_call_count": str(result.tool_call_count),
                "decisions_processed": str(len(result.decision_ids_processed)),
                "execution_duration_ms": str(result.execution_duration_ms),
                "mode": "runtime",
            }
            plan.timestamps.updated_at = datetime.now(tz=UTC)

            # Spawn and execute child subplans from spawn decisions
            spawn_result = self._spawn_subplans(plan)
            if spawn_result is not None:
                exec_result = self._execute_subplans(plan, spawn_result)
                self._apply_subplan_results_to_plan(plan, spawn_result, exec_result)
                plan.timestamps.updated_at = datetime.now(tz=UTC)

            self._lifecycle._commit_plan(plan)
            self._try_create_checkpoint(plan_id, "post_execute", {"status": "success"})
            self._lifecycle.complete_execute(plan_id)
            self._try_emit_metric(
                OperationalMetricKey.PLAN_DURATION_MS, plan_id, _duration_ms
            )
            self._logger.info(
                "Execute completed (runtime)",
                plan_id=plan_id,
                changeset_id=result.changeset_id,
                tool_calls=result.tool_call_count,
            )
            return result
        except Exception as exc:
            # on_error: create checkpoint after any unrecoverable error
            # in the Execute phase (before rollback).
            if self._is_auto_trigger_active("on_error"):
                self._try_create_checkpoint(
                    plan_id,
                    "on_error",
                    {
                        "exception_type": type(exc).__name__,
                        "mode": "runtime",
                    },
                )
            self._try_rollback_to_last_checkpoint(plan_id)
            error_msg = f"{type(exc).__name__}: {exc}"
            plan = self._lifecycle.get_plan(plan_id)
            plan.error_details = {
                "exception_type": type(exc).__name__,
                "traceback": traceback.format_exc(),
                "mode": "runtime",
            }
            self._lifecycle._commit_plan(plan)
            self._lifecycle.fail_execute(plan_id, error_msg)
            raise

    def _run_execute_with_stub(
        self,
        plan_id: str,
        stream_callback: StreamCallback | None = None,
    ) -> ExecuteResult:
        """Execute using the legacy ExecuteStubActor with optional retry."""
        plan = self._guard_execute(plan_id)
        decisions = self._build_decisions(plan)

        self._lifecycle.start_execute(plan_id)
        self._try_create_checkpoint(plan_id, "pre_execute")
        _start_ns = time.monotonic_ns()

        # Determine max attempts: 1 (no recovery) or policy max_retries + 1.
        max_attempts = (
            self._error_recovery.max_retries + 1
            if self._error_recovery is not None
            else 1
        )
        last_exc: Exception | None = None

        for attempt in range(max_attempts):
            try:
                # Enforce per-step guardrails for each decision
                for _decision in decisions:
                    self._enforce_guardrails_per_step(plan_id)

                result = self._execute_actor.execute(
                    plan_id=plan_id,
                    decisions=decisions,
                    tool_runner=self._tool_runner,
                    sandbox_root=self._sandbox_root,
                    stream_callback=stream_callback,
                    read_only=getattr(plan, "read_only", False),
                )
                plan = self._lifecycle.get_plan(plan_id)
                plan.changeset_id = result.changeset_id
                plan.sandbox_refs = result.sandbox_refs
                plan.error_details = {
                    "tool_calls_count": str(result.tool_calls_count),
                    "sandbox_refs_count": str(len(result.sandbox_refs)),
                    "mode": "stub",
                }
                plan.timestamps.updated_at = datetime.now(tz=UTC)

                # Spawn and execute child subplans from spawn decisions
                spawn_result = self._spawn_subplans(plan)
                if spawn_result is not None:
                    exec_result = self._execute_subplans(plan, spawn_result)
                    self._apply_subplan_results_to_plan(plan, spawn_result, exec_result)
                    plan.timestamps.updated_at = datetime.now(tz=UTC)

                self._lifecycle._commit_plan(plan)
                self._try_create_checkpoint(
                    plan_id, "post_execute", {"status": "success"}
                )
                self._lifecycle.complete_execute(plan_id)
                _duration_ms = (time.monotonic_ns() - _start_ns) / 1_000_000
                self._try_emit_metric(
                    OperationalMetricKey.PLAN_DURATION_MS, plan_id, _duration_ms
                )
                self._logger.info(
                    "Execute completed (stub)",
                    plan_id=plan_id,
                    changeset_id=result.changeset_id,
                    tool_calls=result.tool_calls_count,
                )
                return result

            except Exception as exc:
                last_exc = exc

                # Record error via recovery service if available
                if self._error_recovery is not None:
                    self._error_recovery.record_error(
                        plan_id=plan_id,
                        phase="execute",
                        message=str(exc),
                        exception=exc,
                        exception_type=type(exc).__name__,
                    )
                    # Check if retry is available
                    if self._error_recovery.should_retry(plan_id):
                        self._logger.info(
                            "Retrying execute phase after recoverable error",
                            plan_id=plan_id,
                            attempt=attempt + 1,
                            max_attempts=max_attempts,
                            error=str(exc),
                        )
                        continue

                # No recovery service or no more retries — fail
                break

        # All attempts exhausted — attempt rollback, persist error, fail
        assert last_exc is not None
        # on_error: create checkpoint after unrecoverable error in Execute
        # phase (before rollback, so the error state is captured).
        if self._is_auto_trigger_active("on_error"):
            self._try_create_checkpoint(
                plan_id,
                "on_error",
                {
                    "exception_type": type(last_exc).__name__,
                    "mode": "stub",
                },
            )
        self._try_rollback_to_last_checkpoint(plan_id)
        error_msg = f"{type(last_exc).__name__}: {last_exc}"
        plan = self._lifecycle.get_plan(plan_id)
        plan.error_details = {
            "exception_type": type(last_exc).__name__,
            "traceback": traceback.format_exc(),
            "mode": "stub",
        }
        self._lifecycle._commit_plan(plan)
        self._lifecycle.fail_execute(plan_id, error_msg)
        raise last_exc


# ---------------------------------------------------------------------------
# Sandbox root proxy (for checkpoint hooks without a full Sandbox object)
# ---------------------------------------------------------------------------


class _SandboxRootProxy:
    """Lightweight proxy providing sandbox-like interface for checkpoint hooks.

    Used when only a ``sandbox_root`` path string is available (no real
    ``Sandbox`` instance).  Satisfies the minimal interface required by
    :meth:`CheckpointManager.create_checkpoint`.
    """

    def __init__(self, sandbox_root: str, plan_id: str) -> None:
        self._sandbox_id = f"root-{plan_id[:16]}"
        self._context = _ProxyContext(sandbox_root)

    @property
    def sandbox_id(self) -> str:
        """Return a synthetic sandbox ID."""
        return self._sandbox_id

    @property
    def context(self) -> _ProxyContext:
        """Return a proxy context with sandbox_path."""
        return self._context


class _ProxyContext:
    """Minimal context providing ``sandbox_path`` for checkpoint snapshotting."""

    def __init__(self, sandbox_path: str) -> None:
        self.sandbox_path = sandbox_path
