"""Sequential pipeline engine for the Triad Orchestrator.

Runs 4 stages in order: Architect -> Implement -> Refactor -> Verify.
Each stage loads its role prompt template, renders it with task context
and previous output, calls a LiteLLMProvider, and passes its output
to the next stage. Cross-domain suggestions are collected and injected
downstream. All AgentMessages are logged to a session audit trail.

The Arbiter layer reviews stage outputs (based on arbiter_mode) using
cross-model enforcement. REJECT triggers a retry with structured feedback,
HALT stops the pipeline. Optional ISR reconciliation runs after Verify.
"""

from __future__ import annotations

import asyncio
import logging
import re
import time
import uuid
from collections.abc import Callable
from datetime import UTC, datetime

from triad.arbiter.arbiter import ArbiterEngine
from triad.arbiter.feedback import format_arbiter_feedback
from triad.arbiter.reconciler import ReconciliationEngine
from triad.consensus.protocol import ConsensusEngine
from triad.prompts import render_prompt
from triad.providers.health import ProviderHealth
from triad.providers.litellm_provider import LiteLLMProvider, _short_error_reason
from triad.routing.engine import RoutingEngine
from triad.schemas.arbiter import ArbiterReview, Verdict
from triad.schemas.consensus import (
    ConsensusResult,
    DebateResult,
    ImproveResult,
    ParallelResult,
    ReviewResult,
    SuggestionDecision,
    SuggestionVerdict,
)
from triad.schemas.messages import (
    AgentMessage,
    MessageType,
    PipelineStage,
    Suggestion,
)
from triad.schemas.pipeline import (
    ArbiterMode,
    ModelConfig,
    PipelineConfig,
    PipelineMode,
    PipelineResult,
    TaskSpec,
)
from triad.schemas.routing import RoutingDecision
from triad.schemas.session import SessionRecord, StageRecord

logger = logging.getLogger(__name__)

# Ordered pipeline stages
_STAGE_SEQUENCE: list[PipelineStage] = [
    PipelineStage.ARCHITECT,
    PipelineStage.IMPLEMENT,
    PipelineStage.REFACTOR,
    PipelineStage.VERIFY,
]

# Map pipeline stages to prompt template file names (without .md)
_STAGE_PROMPT: dict[PipelineStage, str] = {
    PipelineStage.ARCHITECT: "architect",
    PipelineStage.IMPLEMENT: "implementer",
    PipelineStage.REFACTOR: "refactorer",
    PipelineStage.VERIFY: "verifier",
}

# Map pipeline stages to the message type each produces
_STAGE_MSG_TYPE: dict[PipelineStage, MessageType] = {
    PipelineStage.ARCHITECT: MessageType.PROPOSAL,
    PipelineStage.IMPLEMENT: MessageType.IMPLEMENTATION,
    PipelineStage.REFACTOR: MessageType.IMPLEMENTATION,
    PipelineStage.VERIFY: MessageType.VERIFICATION,
}

# Map each stage to the next stage in the pipeline
_NEXT_STAGE: dict[PipelineStage, PipelineStage] = {
    PipelineStage.ARCHITECT: PipelineStage.IMPLEMENT,
    PipelineStage.IMPLEMENT: PipelineStage.REFACTOR,
    PipelineStage.REFACTOR: PipelineStage.VERIFY,
    PipelineStage.VERIFY: PipelineStage.VERIFY,
}

# Which stages the Arbiter reviews in each mode
_ARBITER_STAGES: dict[ArbiterMode, set[PipelineStage]] = {
    ArbiterMode.OFF: set(),
    ArbiterMode.FINAL_ONLY: {PipelineStage.VERIFY},
    ArbiterMode.BOOKEND: {PipelineStage.ARCHITECT, PipelineStage.VERIFY},
    ArbiterMode.FULL: {
        PipelineStage.ARCHITECT,
        PipelineStage.IMPLEMENT,
        PipelineStage.REFACTOR,
        PipelineStage.VERIFY,
    },
}

# Regex for extracting CONFIDENCE: <value> from model output
_CONFIDENCE_RE = re.compile(r"CONFIDENCE:\s*([\d.]+)")


class PipelineOrchestrator:
    """Sequential pipeline engine with Arbiter integration.

    Coordinates the 4-stage pipeline (Architect -> Implement -> Refactor ->
    Verify), passing output between stages, collecting cross-domain
    suggestions, and tracking tokens/cost. The Arbiter layer reviews
    stage outputs and can trigger retries (REJECT), halt the pipeline
    (HALT), or inject flags (FLAG). Optional ISR reconciliation runs
    after the Verify stage.
    """

    def __init__(
        self,
        task: TaskSpec,
        config: PipelineConfig,
        registry: dict[str, ModelConfig],
        event_emitter: object | None = None,
        stream_callback: Callable | None = None,
    ) -> None:
        self._task = task
        self._config = config
        self._registry = registry
        self._emitter = event_emitter
        self._stream_callback = stream_callback
        self._health = ProviderHealth()
        self._session: list[AgentMessage] = []
        self._arbiter = ArbiterEngine(config, registry, health=self._health)
        self._reconciler = ReconciliationEngine(config, registry)
        context_tokens = config.context_token_budget if config.context_dir else 0
        task_tokens = max(100, len(task.task) // 4) if task.task else 500
        self._router = RoutingEngine(
            config, registry, health=self._health,
            context_tokens=context_tokens, task_tokens=task_tokens,
        )
        self._consensus = ConsensusEngine(config, registry)
        self._routing_decisions: list[RoutingDecision] = []
        self._suggestion_decisions: list[SuggestionDecision] = []
        self._stage_models: dict[PipelineStage, str] = {}
        self._model_fallbacks: list[dict] = []
        # Precomputed primary decisions (with diversity enforcement)
        self._planned_decisions: dict[PipelineStage, RoutingDecision] = {
            d.role: d for d in self._router.select_pipeline_models()
        }

    async def _emit(self, event_type: str, **data: object) -> None:
        """Emit a dashboard event if an emitter is attached."""
        if self._emitter is not None:
            await self._emitter.emit(event_type, **data)

    def _display_name(self, model_key: str) -> str:
        """Resolve a registry key to its display_name for UI events."""
        cfg = self._registry.get(model_key)
        return cfg.display_name if cfg else model_key

    def _display_name_from_litellm_id(self, litellm_id: str) -> str:
        """Resolve a LiteLLM model ID to a display_name."""
        for cfg in self._registry.values():
            if cfg.model == litellm_id:
                return cfg.display_name
        return litellm_id

    @property
    def session(self) -> list[AgentMessage]:
        """The session audit trail — all AgentMessages from the run."""
        return list(self._session)

    async def run(self) -> PipelineResult:
        """Execute the full 4-stage pipeline and return a PipelineResult.

        The Arbiter reviews stages based on arbiter_mode. REJECT triggers
        a retry with feedback injection (up to max_retries). HALT stops
        the pipeline immediately. FLAG injects warnings into downstream
        context. After Verify, optional ISR reconciliation checks for
        spec drift.

        Raises:
            RuntimeError: If model selection fails or a stage errors out.
        """
        start = time.monotonic()

        stages: dict[PipelineStage, AgentMessage] = {}
        all_suggestions: list[Suggestion] = []
        arbiter_reviews: list[ArbiterReview] = []
        architect_output = ""
        implement_output = ""
        refactor_output = ""
        previous_output = ""
        flagged_issues = ""
        halted = False
        halt_reason = ""

        reviewed_stages = _ARBITER_STAGES.get(
            self._config.arbiter_mode, set()
        )

        await self._emit(
            "pipeline_started",
            mode=self._config.pipeline_mode.value,
            task_summary=self._task.task[:200],
            models=list(self._registry.keys()),
        )

        for stage in _STAGE_SEQUENCE:
            logger.info("Starting stage: %s", stage.value)

            stage_start = time.monotonic()

            template_vars = self._build_template_vars(
                stage=stage,
                previous_output=previous_output,
                architect_output=architect_output,
                implement_output=implement_output,
                refactor_output=refactor_output,
                suggestions=all_suggestions,
                flagged_issues=flagged_issues,
            )

            # _run_stage selects the model and emits stage_started
            msg = await self._run_stage(stage, template_vars)

            stages[stage] = msg
            self._session.append(msg)

            # Chain output: current output becomes next stage's input
            previous_output = msg.content
            if stage == PipelineStage.ARCHITECT:
                architect_output = msg.content
            elif stage == PipelineStage.IMPLEMENT:
                implement_output = msg.content
            elif stage == PipelineStage.REFACTOR:
                refactor_output = msg.content

            stage_duration = time.monotonic() - stage_start
            stage_cost = msg.token_usage.cost if msg.token_usage else 0.0
            await self._emit(
                "stage_completed",
                stage=stage.value,
                model=self._display_name(self._stage_models[stage]),
                duration=stage_duration,
                cost=stage_cost,
                confidence=msg.confidence,
                content_preview=msg.content[:200],
            )

            logger.info(
                "Completed stage %s (model=%s, confidence=%.2f)",
                stage.value,
                msg.model,
                msg.confidence,
            )

            # --- Evaluate cross-domain suggestions ---
            accepted_suggestions = await self._evaluate_suggestions(
                new_suggestions=msg.suggestions,
                current_stage=stage,
                previous_output=previous_output,
            )
            all_suggestions.extend(accepted_suggestions)

            # --- Arbiter review ---
            if stage in reviewed_stages:
                await self._emit(
                    "arbiter_started",
                    stage=stage.value,
                    arbiter_model=self._display_name_from_litellm_id(
                        msg.model,
                    ),
                )
                review = await self._arbiter.review(
                    stage=stage,
                    stage_model=msg.model,
                    stage_output=msg.content,
                    task=self._task,
                    architect_output=architect_output,
                )
                if review is None:
                    # All arbiter models unavailable — skip review
                    await self._emit(
                        "arbiter_skipped",
                        stage=stage.value,
                        reason="No arbiter models available",
                    )
                    continue

                arbiter_reviews.append(review)
                await self._emit(
                    "arbiter_verdict",
                    stage=stage.value,
                    verdict=review.verdict.value,
                    confidence=review.confidence,
                    issues_count=len(review.issues),
                    reasoning_preview=review.reasoning[:200],
                    arbiter_model=self._display_name_from_litellm_id(
                        review.arbiter_model,
                    ),
                )

                if review.verdict == Verdict.HALT:
                    halted = True
                    halt_reason = review.reasoning
                    logger.warning(
                        "HALT verdict for %s — stopping pipeline", stage.value
                    )
                    await self._emit(
                        "pipeline_halted",
                        stage=stage.value,
                        reason=halt_reason[:200],
                        arbiter_model=self._display_name_from_litellm_id(
                            review.arbiter_model,
                        ),
                    )
                    break

                if review.verdict == Verdict.REJECT:
                    await self._emit(
                        "retry_triggered",
                        stage=stage.value,
                        retry_number=1,
                        reason=review.reasoning[:200],
                    )
                    msg, retry_reviews = await self._retry_stage(
                        stage=stage,
                        review=review,
                        previous_output=previous_output,
                        architect_output=architect_output,
                        implement_output=implement_output,
                        refactor_output=refactor_output,
                        suggestions=all_suggestions,
                        flagged_issues=flagged_issues,
                    )
                    arbiter_reviews.extend(retry_reviews)

                    # Check if retries ended with HALT
                    if retry_reviews and retry_reviews[-1].verdict == Verdict.HALT:
                        halted = True
                        halt_reason = retry_reviews[-1].reasoning
                        break

                    # Update stage output with the successful retry
                    stages[stage] = msg
                    self._session.append(msg)
                    previous_output = msg.content
                    if stage == PipelineStage.ARCHITECT:
                        architect_output = msg.content
                    elif stage == PipelineStage.IMPLEMENT:
                        implement_output = msg.content
                    elif stage == PipelineStage.REFACTOR:
                        refactor_output = msg.content

                    # Emit final stage_completed with cumulative totals
                    retry_msg_cost = msg.token_usage.cost if msg.token_usage else 0.0
                    retry_arbiter_cost = sum(r.token_cost for r in retry_reviews)
                    total_stage_cost = stage_cost + retry_msg_cost + retry_arbiter_cost
                    total_stage_duration = time.monotonic() - stage_start
                    await self._emit(
                        "stage_completed",
                        stage=stage.value,
                        model=self._display_name(self._stage_models[stage]),
                        duration=total_stage_duration,
                        cost=total_stage_cost,
                        confidence=msg.confidence,
                        content_preview=msg.content[:200],
                    )

                if review.verdict == Verdict.FLAG:
                    flagged_issues = self._format_flags(review)

        # --- ISR Reconciliation ---
        recon_review = None
        if (
            not halted
            and self._config.reconciliation_enabled
            and PipelineStage.VERIFY in stages
        ):
            verify_msg = stages[PipelineStage.VERIFY]
            recon_review = await self._reconciler.reconcile(
                task=self._task,
                architect_output=architect_output,
                implementation_summary=verify_msg.content,
                verifier_model=verify_msg.model,
            )
            arbiter_reviews.append(recon_review)

            if recon_review.verdict == Verdict.HALT:
                halted = True
                halt_reason = recon_review.reasoning

        # --- Build result ---
        duration = time.monotonic() - start
        total_cost = sum(
            msg.token_usage.cost for msg in stages.values() if msg.token_usage
        )
        # Include arbiter costs
        total_cost += sum(r.token_cost for r in arbiter_reviews)

        total_tokens = sum(
            (msg.token_usage.prompt_tokens + msg.token_usage.completion_tokens)
            for msg in stages.values()
            if msg.token_usage
        )

        if not halted:
            await self._emit(
                "pipeline_completed",
                success=True,
                total_cost=total_cost,
                total_tokens=total_tokens,
                duration=duration,
                session_id="",
            )

        return PipelineResult(
            task=self._task,
            config=self._config,
            stages=stages,
            suggestions=all_suggestions,
            total_cost=total_cost,
            total_tokens=total_tokens,
            duration_seconds=duration,
            success=not halted,
            arbiter_reviews=arbiter_reviews,
            halted=halted,
            halt_reason=halt_reason,
            routing_decisions=self._routing_decisions,
            model_fallbacks=self._model_fallbacks,
        )

    async def _retry_stage(
        self,
        stage: PipelineStage,
        review: ArbiterReview,
        previous_output: str,
        architect_output: str,
        suggestions: list[Suggestion],
        flagged_issues: str,
        implement_output: str = "",
        refactor_output: str = "",
    ) -> tuple[AgentMessage, list[ArbiterReview]]:
        """Retry a stage after REJECT, injecting Arbiter feedback.

        Returns the final AgentMessage and any additional ArbiterReviews
        from the retry attempts.
        """
        stage_config = self._config.stages.get(stage)
        max_retries = stage_config.max_retries if stage_config else self._config.max_retries
        retry_reviews: list[ArbiterReview] = []
        current_review = review

        for retry in range(1, max_retries + 1):
            logger.info(
                "Retrying stage %s (attempt %d/%d) after REJECT",
                stage.value, retry, max_retries,
            )

            feedback = format_arbiter_feedback(current_review, retry)
            template_vars = self._build_template_vars(
                stage=stage,
                previous_output=previous_output,
                architect_output=architect_output,
                implement_output=implement_output,
                refactor_output=refactor_output,
                suggestions=suggestions,
                flagged_issues=flagged_issues,
                arbiter_feedback=feedback,
                retry_number=retry,
            )

            # _run_stage emits stage_started with the selected model
            msg = await self._run_stage(stage, template_vars)

            # Re-review the retried output
            reviewed_stages = _ARBITER_STAGES.get(
                self._config.arbiter_mode, set()
            )
            if stage in reviewed_stages:
                await self._emit(
                    "arbiter_started",
                    stage=stage.value,
                    arbiter_model=self._display_name_from_litellm_id(
                        msg.model,
                    ),
                )
                retry_review = await self._arbiter.review(
                    stage=stage,
                    stage_model=msg.model,
                    stage_output=msg.content,
                    task=self._task,
                    architect_output=architect_output,
                )
                if retry_review is None:
                    # Arbiter unavailable — treat as passed
                    await self._emit(
                        "arbiter_skipped",
                        stage=stage.value,
                        reason="No arbiter models available for retry review",
                    )
                    return msg, retry_reviews

                retry_reviews.append(retry_review)
                await self._emit(
                    "arbiter_verdict",
                    stage=stage.value,
                    verdict=retry_review.verdict.value,
                    confidence=retry_review.confidence,
                    issues_count=len(retry_review.issues),
                    reasoning_preview=retry_review.reasoning[:200],
                    arbiter_model=self._display_name_from_litellm_id(
                        retry_review.arbiter_model,
                    ),
                )

                if retry_review.verdict in (Verdict.APPROVE, Verdict.FLAG):
                    return msg, retry_reviews

                if retry_review.verdict == Verdict.HALT:
                    return msg, retry_reviews

                # Still REJECT — continue loop with new feedback
                await self._emit(
                    "retry_triggered",
                    stage=stage.value,
                    retry_number=retry + 1,
                    reason=retry_review.reasoning[:200],
                )
                current_review = retry_review
            else:
                # Not re-reviewed (shouldn't happen, but safe fallback)
                return msg, retry_reviews

        # Exhausted retries — return last attempt
        logger.warning(
            "Stage %s exhausted %d retries, proceeding with last output",
            stage.value, max_retries,
        )
        return msg, retry_reviews

    _MAX_FALLBACK_ATTEMPTS = 3

    async def _run_stage(
        self,
        stage: PipelineStage,
        template_vars: dict,
    ) -> AgentMessage:
        """Execute a single pipeline stage with automatic model fallback.

        Tries up to 3 different models if the primary selection fails with
        a transient error (rate limit, timeout, server error). Failed models
        are marked unhealthy so subsequent stages skip them.

        Raises:
            RuntimeError: If all models for the stage are exhausted.
        """
        tried_models: list[str] = []
        last_error: Exception | None = None
        first_decision: RoutingDecision | None = None

        # Render prompt once — it's the same regardless of which model runs
        prompt_name = _STAGE_PROMPT[stage]
        system = render_prompt(prompt_name, **template_vars)
        timeout = self._get_timeout(stage)

        for attempt in range(self._MAX_FALLBACK_ATTEMPTS):
            # Select model: primary on first attempt, fallback thereafter
            if attempt == 0:
                decision = self._planned_decisions.get(
                    stage, self._router.select_model(stage),
                )
                first_decision = decision
                # Skip directly to fallback if primary is already unhealthy
                if not self._health.is_healthy(decision.model_key):
                    tried_models.append(decision.model_key)
                    last_error = RuntimeError(
                        f"{decision.model_key} already marked unhealthy"
                    )
                    logger.info(
                        "Primary model %s unhealthy for %s, skipping to fallback",
                        decision.model_key, stage.value,
                    )
                    continue
            else:
                decision = self._router.get_fallback(stage, tried_models)
                if decision is None:
                    break  # no more models available

                original_name = self._display_name(first_decision.model_key)
                original_fitness = first_decision.fitness_score
                fallback_name = self._display_name(decision.model_key)
                fallback_fitness = decision.fitness_score
                short_reason = _short_error_reason(last_error)

                self._model_fallbacks.append({
                    "stage": stage.value,
                    "original": original_name,
                    "original_fitness": original_fitness,
                    "fallback": fallback_name,
                    "fallback_fitness": fallback_fitness,
                    "reason": short_reason,
                })
                await self._emit(
                    "model_fallback",
                    stage=stage.value,
                    original_model=original_name,
                    fallback_model=fallback_name,
                    reason=short_reason,
                )

            self._routing_decisions.append(decision)
            self._stage_models[stage] = decision.model_key
            model_config = self._registry[decision.model_key]
            tried_models.append(decision.model_key)

            await self._emit(
                "stage_started",
                stage=stage.value,
                model=model_config.display_name,
            )

            try:
                provider = LiteLLMProvider(model_config)
                user_msg = [{
                    "role": "user",
                    "content": (
                        "Execute the task as described in your "
                        "system instructions."
                    ),
                }]

                if self._stream_callback is not None:
                    # Wrap callback to include stage info and emit events
                    async def _on_chunk(chunk):
                        await self._stream_callback(stage, chunk)
                        await self._emit(
                            "token_delta",
                            stage=stage.value,
                            delta=chunk.delta,
                            accumulated_length=len(chunk.accumulated),
                            token_count=chunk.token_count,
                        )

                    msg = await provider.complete_streaming(
                        messages=user_msg,
                        system=system,
                        timeout=timeout,
                        on_chunk=_on_chunk,
                    )
                else:
                    msg = await provider.complete(
                        messages=user_msg,
                        system=system,
                        timeout=timeout,
                    )

                # Set routing metadata
                msg.from_agent = stage
                msg.to_agent = _NEXT_STAGE[stage]
                msg.msg_type = _STAGE_MSG_TYPE[stage]
                msg.confidence = _extract_confidence(msg.content)
                return msg

            except (RuntimeError, TimeoutError) as e:
                last_error = e
                self._health.mark_unhealthy(decision.model_key)
                short_reason = _short_error_reason(e)
                logger.warning(
                    "%s unavailable for %s (%s), falling back",
                    model_config.display_name, stage.value, short_reason,
                )
                await self._emit(
                    "error",
                    error=(
                        f"{model_config.display_name} unavailable "
                        f"({short_reason})"
                    ),
                )

        # All models exhausted
        raise RuntimeError(
            f"All models failed for {stage.value} after "
            f"{len(tried_models)} attempt(s). "
            f"Tried: {', '.join(tried_models)}. Last error: {last_error}"
        )

    def _get_timeout(self, stage: PipelineStage) -> int:
        """Get the timeout in seconds for a pipeline stage."""
        stage_config = self._config.stages.get(stage)
        if stage_config:
            return stage_config.timeout
        return self._config.default_timeout

    def _build_template_vars(
        self,
        stage: PipelineStage,
        previous_output: str,
        architect_output: str,
        suggestions: list[Suggestion],
        implement_output: str = "",
        refactor_output: str = "",
        flagged_issues: str = "",
        arbiter_feedback: str = "",
        retry_number: int = 0,
    ) -> dict:
        """Build the Jinja2 template variables for a stage prompt."""
        tpl_vars: dict = {
            "task": self._task.task,
            "context": self._task.context,
            "domain_context": self._task.domain_rules,
        }

        # Always set previous_output so templates render it (even if empty)
        tpl_vars["previous_output"] = previous_output

        # Verifier receives the full pipeline context chain
        if stage == PipelineStage.VERIFY:
            if architect_output:
                tpl_vars["architect_output"] = architect_output
            if implement_output:
                tpl_vars["implement_output"] = implement_output
            # Ensure verify always sees the refactor output as previous_output.
            # If refactor_output is available, prefer it over whatever
            # previous_output was set to (handles retry edge cases).
            if refactor_output:
                tpl_vars["previous_output"] = refactor_output

        # Format upstream suggestions targeted at this stage
        upstream = _format_suggestions(suggestions, stage)
        if upstream:
            tpl_vars["upstream_suggestions"] = upstream

        # Reconciliation flag for the verifier
        if stage == PipelineStage.VERIFY:
            tpl_vars["reconciliation_enabled"] = self._config.reconciliation_enabled

        # Arbiter FLAG warnings from a previous stage
        if flagged_issues:
            tpl_vars["flagged_issues"] = flagged_issues

        # Arbiter REJECT feedback for retry
        if arbiter_feedback:
            tpl_vars["arbiter_feedback"] = arbiter_feedback
            tpl_vars["retry_number"] = retry_number

        return tpl_vars

    def _format_flags(self, review: ArbiterReview) -> str:
        """Format a FLAG review's issues for downstream injection."""
        if not review.issues:
            return f"The Arbiter flagged concerns (confidence: {review.confidence:.2f}). " \
                   f"Review and address if appropriate."

        lines: list[str] = [
            "The Arbiter flagged the following concerns from a previous stage:"
        ]
        for issue in review.issues:
            lines.append(
                f"- [{issue.severity.value}] [{issue.category.value}] {issue.description}"
            )
        return "\n".join(lines)

    async def _evaluate_suggestions(
        self,
        new_suggestions: list[Suggestion],
        current_stage: PipelineStage,
        previous_output: str,
    ) -> list[Suggestion]:
        """Evaluate cross-domain suggestions from the current stage.

        Each suggestion is evaluated by the primary role-holder for its
        target domain. Rejected high-confidence suggestions (>0.7) are
        escalated to a group vote. Returns only accepted suggestions.
        """
        if not new_suggestions:
            return []

        accepted: list[Suggestion] = []
        pipeline_models = {
            stage.value: key
            for stage, key in self._stage_models.items()
        }

        for suggestion in new_suggestions:
            # Find the primary role-holder for this suggestion's domain
            target_model = self._stage_models.get(suggestion.domain)
            if not target_model:
                # Domain's model not yet assigned — accept by default
                accepted.append(suggestion)
                continue

            decision = await self._consensus.evaluate_suggestion(
                suggestion=suggestion,
                evaluator_key=target_model,
                task=self._task.task,
                current_approach=previous_output,
            )
            self._suggestion_decisions.append(decision)

            if decision.decision == SuggestionVerdict.ACCEPT:
                accepted.append(suggestion)
                continue

            # Rejected — check for escalation
            if self._consensus.should_escalate(suggestion):
                logger.info(
                    "Escalating high-confidence suggestion "
                    "(confidence=%.2f) to group vote",
                    suggestion.confidence,
                )
                escalation = await self._consensus.escalate_suggestion(
                    suggestion=suggestion,
                    pipeline_models=pipeline_models,
                    task=self._task.task,
                    current_approach=previous_output,
                )
                if escalation.decision == SuggestionVerdict.ACCEPT:
                    accepted.append(suggestion)

        return accepted


def _extract_confidence(content: str) -> float:
    """Extract the CONFIDENCE: <value> score from model output.

    Returns 0.0 if no valid confidence score is found.
    """
    match = _CONFIDENCE_RE.search(content)
    if match:
        try:
            val = float(match.group(1))
            return max(0.0, min(1.0, val))
        except ValueError:
            pass
    return 0.0


def _format_suggestions(
    suggestions: list[Suggestion], target_stage: PipelineStage
) -> str:
    """Format suggestions targeted at a specific stage into readable text.

    Filters suggestions by domain and returns a formatted string for
    injection into the downstream prompt template.

    Returns:
        Formatted string, or empty string if no relevant suggestions.
    """
    relevant = [s for s in suggestions if s.domain == target_stage]
    if not relevant:
        return ""

    lines: list[str] = []
    for s in relevant:
        line = f"- {s.rationale} (confidence: {s.confidence})"
        if s.code_sketch:
            line += f"\n  Code sketch: {s.code_sketch}"
        if s.impact_assessment:
            line += f"\n  Impact: {s.impact_assessment}"
        lines.append(line)

    return "\n".join(lines)


# ── Score extraction for parallel cross-review ────────────────────

_ARCH_SCORE_RE = re.compile(r"ARCHITECTURE:\s*(\d+)")
_IMPL_SCORE_RE = re.compile(r"IMPLEMENTATION:\s*(\d+)")
_QUALITY_SCORE_RE = re.compile(r"QUALITY:\s*(\d+)")


def _extract_scores(content: str) -> dict[str, int]:
    """Extract architecture/implementation/quality scores from review."""
    scores: dict[str, int] = {}
    for name, pattern in [
        ("architecture", _ARCH_SCORE_RE),
        ("implementation", _IMPL_SCORE_RE),
        ("quality", _QUALITY_SCORE_RE),
    ]:
        match = pattern.search(content)
        if match:
            scores[name] = max(1, min(10, int(match.group(1))))
        else:
            scores[name] = 5  # neutral default
    return scores


def _get_tiebreaker_key(registry: dict[str, ModelConfig]) -> str:
    """Select the tiebreaker model — highest-fitness verifier."""
    return max(registry, key=lambda k: registry[k].fitness.verifier)


# ── Parallel Exploration Orchestrator ─────────────────────────────


def _select_top_models(
    registry: dict[str, ModelConfig],
    n: int = 3,
) -> dict[str, ModelConfig]:
    """Select the top N models from the registry with provider diversity.

    Used by parallel and debate orchestrators to avoid fanning out to
    the entire registry (which could be 8+ models). Enforces max 1
    model per provider: picks the highest-fitness model from each
    provider first, then fills remaining slots from providers whose
    best model was already taken. This prevents two models from the
    same provider (e.g. Claude Opus + Claude Sonnet) from occupying
    multiple fan-out slots.

    Returns at least 2 models (minimum for parallel/debate), or the
    full registry if it has fewer than N entries.
    """
    if len(registry) <= n:
        return registry

    def avg_fitness(cfg: ModelConfig) -> float:
        f = cfg.fitness
        return (f.architect + f.implementer + f.refactorer + f.verifier) / 4

    ranked = sorted(registry.items(), key=lambda kv: avg_fitness(kv[1]), reverse=True)

    # Pass 1: one model per provider (highest fitness from each)
    selected: dict[str, ModelConfig] = {}
    used_providers: set[str] = set()
    remaining: list[tuple[str, ModelConfig]] = []

    for key, cfg in ranked:
        if len(selected) >= n:
            break
        if cfg.provider not in used_providers:
            selected[key] = cfg
            used_providers.add(cfg.provider)
        else:
            remaining.append((key, cfg))

    # Pass 2: if fewer unique providers than N, fill from next-best models
    for key, cfg in remaining:
        if len(selected) >= n:
            break
        selected[key] = cfg

    return selected


def _detect_incomplete_sections(output: str) -> list[str]:
    """Scan synthesized output for signs of incompleteness.

    Returns a list of human-readable issue descriptions. An empty list
    means no incompleteness was detected.
    """
    issues: list[str] = []

    # Check for placeholder function bodies
    if re.search(r"^\s+(pass|\.\.\.)\s*$", output, re.MULTILINE):
        issues.append("Functions with `pass` or `...` placeholder bodies")

    # Check for TODO/FIXME comments
    if re.search(r"#\s*(TODO|FIXME)", output):
        issues.append("TODO/FIXME comments indicating unfinished work")

    # Check for files listed in a structure tree but never implemented
    declared_files = set(re.findall(r"[├└│─\-\|]\s*(\S+\.py)\b", output))
    implemented_files = set(
        re.findall(r"^#\s*file:\s*(.+\.py)\s*$", output, re.MULTILINE)
    )
    missing = declared_files - implemented_files
    if missing:
        issues.append(
            f"Files listed in structure but never implemented: "
            f"{', '.join(sorted(missing))}"
        )

    # Check for "implementation left as exercise" patterns
    lazy_patterns = [
        r"implementation left as (?:an )?exercise",
        r"implement (?:this|here|the rest)",
        r"add (?:your|the) (?:implementation|logic) here",
        r"placeholder",
    ]
    for pattern in lazy_patterns:
        if re.search(pattern, output, re.IGNORECASE):
            issues.append(
                f"Lazy-implementation language detected (matches: {pattern!r})"
            )
            break

    return issues


class ParallelOrchestrator:
    """Parallel exploration pipeline mode.

    Top models (selected by average fitness) produce independent solutions
    in parallel. Cross-review scoring determines a winner via consensus
    vote. The winner's approach is enhanced with the best elements from
    others.
    """

    _MAX_PARALLEL_MODELS = 3

    def __init__(
        self,
        task: TaskSpec,
        config: PipelineConfig,
        registry: dict[str, ModelConfig],
        event_emitter: object | None = None,
    ) -> None:
        self._task = task
        self._config = config
        self._full_registry = registry
        self._registry = _select_top_models(registry, self._MAX_PARALLEL_MODELS)
        self._emitter = event_emitter
        self._health = ProviderHealth()
        self._arbiter = ArbiterEngine(config, registry, health=self._health)
        self._consensus = ConsensusEngine(config, registry)

    async def _emit(self, event_type: str, **data: object) -> None:
        """Emit a dashboard event if an emitter is attached."""
        if self._emitter is not None:
            await self._emitter.emit(event_type, **data)

    async def run(self) -> PipelineResult:
        """Execute the parallel exploration pipeline."""
        start = time.monotonic()
        arbiter_reviews: list[ArbiterReview] = []
        all_messages: list[AgentMessage] = []
        timeout = self._config.default_timeout
        consensus_result: ConsensusResult | None = None

        await self._emit(
            "pipeline_started",
            mode="parallel",
            task_summary=self._task.task[:200],
            models=list(self._registry.keys()),
        )

        # Phase 1: Fan-out — all models produce solutions in parallel
        logger.info("Parallel fan-out: %d models", len(self._registry))
        await self._emit(
            "stage_started",
            stage="parallel_fan_out",
            model=f"{len(self._registry)} models",
        )
        individual_outputs: dict[str, str] = {}
        fan_out_tasks = {}
        for key, cfg in self._registry.items():
            fan_out_tasks[key] = self._call_model(
                key, cfg, "parallel_generate", timeout,
            )

        results = await asyncio.gather(
            *fan_out_tasks.values(), return_exceptions=True,
        )
        for key, result in zip(fan_out_tasks, results, strict=True):
            if isinstance(result, Exception):
                logger.error("Model %s failed in fan-out: %s", key, result)
                await self._emit(
                    "error",
                    stage="parallel_fan_out",
                    error=f"{key} failed: {result}",
                )
                continue
            individual_outputs[key] = result.content
            all_messages.append(result)

        fan_out_cost = sum(
            m.token_usage.cost for m in all_messages if m.token_usage
        )
        await self._emit(
            "stage_completed",
            stage="parallel_fan_out",
            model=f"{len(individual_outputs)} succeeded",
            duration=time.monotonic() - start,
            cost=fan_out_cost,
        )

        if len(individual_outputs) == 0:
            await self._emit(
                "error",
                stage="parallel_fan_out",
                error="All models failed during fan-out",
            )
            raise RuntimeError(
                "Parallel mode: all models failed during fan-out"
            )

        if len(individual_outputs) == 1:
            # Single-model fallback — skip cross-review/consensus/synthesis
            sole_key = next(iter(individual_outputs))
            sole_output = individual_outputs[sole_key]
            logger.warning(
                "Parallel fan-out: only %s succeeded, falling back to single-model output",
                sole_key,
            )
            await self._emit(
                "warning",
                stage="parallel_fan_out",
                message=f"Only {sole_key} succeeded — falling back to single-model output",
            )
            duration = time.monotonic() - start
            total_cost = sum(
                m.token_usage.cost for m in all_messages if m.token_usage
            )
            total_tokens = sum(
                (m.token_usage.prompt_tokens + m.token_usage.completion_tokens)
                for m in all_messages
                if m.token_usage
            )
            await self._emit(
                "pipeline_completed",
                success=True,
                total_cost=total_cost,
                total_tokens=total_tokens,
                duration=duration,
            )
            return PipelineResult(
                task=self._task,
                config=self._config,
                total_cost=total_cost,
                total_tokens=total_tokens,
                duration_seconds=duration,
                success=True,
                arbiter_reviews=arbiter_reviews,
                parallel_result=ParallelResult(
                    individual_outputs=individual_outputs,
                    scores={},
                    votes={sole_key: sole_key},
                    winner=sole_key,
                    synthesized_output=sole_output,
                ),
            )

        # Phase 2: Cross-review — each model scores the others
        logger.info("Parallel cross-review")
        await self._emit(
            "stage_started",
            stage="parallel_cross_review",
            model=f"{len(individual_outputs)} reviewers",
        )
        scores: dict[str, dict[str, dict[str, int]]] = {}
        review_tasks = []
        review_keys: list[tuple[str, str]] = []

        for reviewer_key in self._registry:
            if reviewer_key not in individual_outputs:
                continue
            reviewer_cfg = self._registry[reviewer_key]
            for reviewed_key, output in individual_outputs.items():
                if reviewed_key == reviewer_key:
                    continue
                review_tasks.append(
                    self._call_model(
                        reviewer_key,
                        reviewer_cfg,
                        "parallel_review",
                        timeout,
                        reviewed_model=reviewed_key,
                        solution=output,
                    )
                )
                review_keys.append((reviewer_key, reviewed_key))

        review_results = await asyncio.gather(
            *review_tasks, return_exceptions=True,
        )
        for (reviewer, reviewed), result in zip(
            review_keys, review_results, strict=True,
        ):
            if isinstance(result, Exception):
                logger.error(
                    "Review %s->%s failed: %s", reviewer, reviewed, result,
                )
                continue
            all_messages.append(result)
            if reviewer not in scores:
                scores[reviewer] = {}
            scores[reviewer][reviewed] = _extract_scores(result.content)

        review_cost = sum(
            m.token_usage.cost for m in all_messages if m.token_usage
        ) - fan_out_cost
        await self._emit(
            "stage_completed",
            stage="parallel_cross_review",
            model=f"{len(review_keys)} reviews",
            duration=time.monotonic() - start,
            cost=review_cost,
        )

        # Phase 3: Vote — derive votes from scores
        votes: dict[str, str] = {}
        for voter, voter_scores in scores.items():
            if not voter_scores:
                continue
            # Vote for the model with highest total score
            votes[voter] = max(
                voter_scores,
                key=lambda k: sum(voter_scores[k].values()),
            )

        # Phase 4: Resolve consensus with formal protocol
        consensus_result = await self._consensus.resolve_consensus(
            votes=votes,
            task=self._task.task,
            candidate_outputs=individual_outputs,
        )
        winner = consensus_result.winner
        logger.info("Parallel winner: %s (method=%s)", winner, consensus_result.method)

        await self._emit(
            "consensus_vote",
            votes=votes,
            winner=winner,
            method=consensus_result.method,
        )

        # Phase 5: Synthesis — merge winning output with best ideas from others.
        # Uses GPT-4o (fast, strong instruction-following) instead of the
        # winning model because synthesis is a structured merge task where
        # speed matters more than depth.
        synth_key = "gpt-4o"
        synth_cfg = self._full_registry.get(synth_key)
        if synth_cfg is None:
            # Fallback to winner if GPT-4o not in registry
            synth_key = winner
            synth_cfg = self._registry[winner]
        logger.info("Parallel synthesis by %s", synth_key)
        await self._emit(
            "stage_started",
            stage="parallel_synthesis",
            model=synth_key,
        )
        winner_cfg = self._registry[winner]
        other_outputs = {
            k: v for k, v in individual_outputs.items() if k != winner
        }
        synth_msg = await self._call_model(
            synth_key,
            synth_cfg,
            "parallel_synthesize",
            timeout,
            winner_model=winner,
            winning_output=individual_outputs[winner],
            other_outputs=other_outputs,
        )
        all_messages.append(synth_msg)
        synthesized_output = synth_msg.content

        synth_cost = synth_msg.token_usage.cost if synth_msg.token_usage else 0.0
        await self._emit(
            "stage_completed",
            stage="parallel_synthesis",
            model=synth_key,
            duration=time.monotonic() - start,
            cost=synth_cost,
        )

        # Phase 5b: Completion pass — fill gaps the synthesis left behind
        incomplete = _detect_incomplete_sections(synthesized_output)
        if incomplete:
            logger.info(
                "Completion pass triggered: %d issues detected", len(incomplete),
            )
            await self._emit(
                "stage_started",
                stage="parallel_completion",
                model=synth_key,
            )
            completion_msg = await self._call_model(
                synth_key,
                synth_cfg,
                "parallel_completion",
                timeout,
                incomplete_sections=incomplete,
                synthesized_output=synthesized_output,
            )
            all_messages.append(completion_msg)
            synthesized_output = completion_msg.content

            completion_cost = (
                completion_msg.token_usage.cost
                if completion_msg.token_usage
                else 0.0
            )
            await self._emit(
                "stage_completed",
                stage="parallel_completion",
                model=synth_key,
                duration=time.monotonic() - start,
                cost=completion_cost,
            )
        else:
            logger.info("Completion pass skipped — no incomplete sections detected")

        # Phase 6: Arbiter review of synthesized output + retry on REJECT
        # Exclude ALL fan-out participants + synthesizer from arbiter selection
        _participant_models = [
            self._registry[k].model for k in individual_outputs
        ]
        _participant_models.append(synth_cfg.model)

        _MAX_SYNTH_RETRIES = 2
        final_reject = False

        if self._config.arbiter_mode != ArbiterMode.OFF:
            review = await self._arbiter.review(
                stage=PipelineStage.VERIFY,
                stage_model=winner_cfg.model,
                stage_output=synthesized_output,
                task=self._task,
                exclude_models=_participant_models,
            )
            if review is not None:
                arbiter_reviews.append(review)

                # Retry synthesis on REJECT with feedback injection
                if review.verdict == Verdict.REJECT:
                    for retry in range(1, _MAX_SYNTH_RETRIES + 1):
                        logger.info(
                            "Retrying parallel synthesis (attempt %d/%d) after REJECT",
                            retry, _MAX_SYNTH_RETRIES,
                        )
                        feedback = format_arbiter_feedback(review, retry)
                        await self._emit(
                            "stage_started",
                            stage="parallel_synthesis_retry",
                            model=synth_key,
                        )
                        synth_msg = await self._call_model(
                            synth_key,
                            synth_cfg,
                            "parallel_synthesis_retry",
                            timeout,
                            previous_synthesis=synthesized_output,
                            arbiter_feedback=feedback,
                        )
                        all_messages.append(synth_msg)
                        synthesized_output = synth_msg.content

                        retry_cost = synth_msg.token_usage.cost if synth_msg.token_usage else 0.0
                        await self._emit(
                            "stage_completed",
                            stage="parallel_synthesis_retry",
                            model=synth_key,
                            duration=time.monotonic() - start,
                            cost=retry_cost,
                        )

                        # Re-review the retried synthesis
                        review = await self._arbiter.review(
                            stage=PipelineStage.VERIFY,
                            stage_model=winner_cfg.model,
                            stage_output=synthesized_output,
                            task=self._task,
                            exclude_models=_participant_models,
                        )
                        if review is not None:
                            arbiter_reviews.append(review)
                            if review.verdict != Verdict.REJECT:
                                break  # Passed or flagged — stop retrying
                        else:
                            break  # Arbiter unavailable — treat as passed

                    # After all retries, check if still rejected
                    final_reject = (
                        review is not None
                        and review.verdict == Verdict.REJECT
                    )
                    if final_reject:
                        logger.warning(
                            "Parallel synthesis still REJECTED after %d retries",
                            _MAX_SYNTH_RETRIES,
                        )

        # Build result
        duration = time.monotonic() - start
        total_cost = sum(
            m.token_usage.cost for m in all_messages if m.token_usage
        ) + sum(r.token_cost for r in arbiter_reviews)
        total_tokens = sum(
            (m.token_usage.prompt_tokens + m.token_usage.completion_tokens)
            for m in all_messages
            if m.token_usage
        )

        halted = any(r.verdict == Verdict.HALT for r in arbiter_reviews)

        if not halted:
            await self._emit(
                "pipeline_completed",
                success=True,
                total_cost=total_cost,
                total_tokens=total_tokens,
                duration=duration,
            )

        return PipelineResult(
            task=self._task,
            config=self._config,
            total_cost=total_cost,
            total_tokens=total_tokens,
            duration_seconds=duration,
            success=not halted,
            arbiter_reviews=arbiter_reviews,
            halted=halted,
            halt_reason=(
                arbiter_reviews[-1].reasoning
                if halted and arbiter_reviews
                else ""
            ),
            parallel_result=ParallelResult(
                individual_outputs=individual_outputs,
                scores=scores,
                votes=votes,
                winner=winner,
                synthesized_output=synthesized_output,
            ),
        )

    _MAX_CALL_ATTEMPTS = 2

    async def _call_model(
        self,
        model_key: str,
        model_config: ModelConfig,
        template_name: str,
        timeout: int,
        **template_vars: object,
    ) -> AgentMessage:
        """Call a model with a rendered prompt template.

        Retries once with a fallback model from the full registry if the
        primary call fails with a transient error.

        Pass ``max_tokens=<int>`` alongside template vars to override the
        model's default max output tokens for this call.
        """
        call_max_tokens: int | None = template_vars.pop("max_tokens", None)
        tpl_vars: dict = {
            "task": self._task.task,
            "context": self._task.context,
            "domain_context": self._task.domain_rules,
            **template_vars,
        }
        system = render_prompt(template_name, **tpl_vars)
        tried: list[str] = []

        for attempt in range(self._MAX_CALL_ATTEMPTS):
            if attempt == 0:
                cfg = model_config
                key = model_key
            else:
                # Fallback: pick the best available model not yet tried
                candidates = {
                    k: v for k, v in self._full_registry.items()
                    if k not in tried and self._health.is_healthy(k)
                }
                if not candidates:
                    break
                key = max(
                    candidates,
                    key=lambda k: (
                        candidates[k].fitness.architect
                        + candidates[k].fitness.implementer
                    ) / 2,
                )
                cfg = candidates[key]
                logger.info(
                    "Parallel fallback %s → %s", model_key, key,
                )

            tried.append(key)
            try:
                provider = LiteLLMProvider(cfg)
                msg = await provider.complete(
                    messages=[{
                        "role": "user",
                        "content": (
                            "Execute the task as described "
                            "in your system instructions."
                        ),
                    }],
                    system=system,
                    timeout=timeout,
                    max_tokens=call_max_tokens,
                )
                msg.from_agent = PipelineStage.ARCHITECT
                msg.to_agent = PipelineStage.ARCHITECT
                msg.msg_type = MessageType.PROPOSAL
                msg.confidence = _extract_confidence(msg.content)
                return msg
            except (RuntimeError, TimeoutError) as e:
                self._health.mark_unhealthy(key)
                logger.warning(
                    "%s failed in parallel call (%s), %s",
                    key,
                    _short_error_reason(e),
                    "falling back" if attempt < self._MAX_CALL_ATTEMPTS - 1 else "giving up",
                )
                last_error = e

        raise RuntimeError(
            f"All models failed for parallel {template_name}: "
            f"tried {tried}. Last error: {last_error}"
        )


# ── Debate Orchestrator ───────────────────────────────────────────


class DebateOrchestrator:
    """Structured debate pipeline mode.

    Top models (selected by average fitness) propose approaches, write
    rebuttals, present final arguments, and a judge model renders a
    decision. The judge is selected from outside the debaters when possible.
    """

    _MAX_DEBATE_MODELS = 3

    def __init__(
        self,
        task: TaskSpec,
        config: PipelineConfig,
        registry: dict[str, ModelConfig],
        event_emitter: object | None = None,
    ) -> None:
        self._task = task
        self._config = config
        self._full_registry = registry
        self._registry = _select_top_models(registry, self._MAX_DEBATE_MODELS)
        self._emitter = event_emitter
        self._health = ProviderHealth()
        self._arbiter = ArbiterEngine(config, registry, health=self._health)
        self._consensus = ConsensusEngine(config, registry)

    async def _emit(self, event_type: str, **data: object) -> None:
        """Emit a dashboard event if an emitter is attached."""
        if self._emitter is not None:
            await self._emitter.emit(event_type, **data)

    async def run(self) -> PipelineResult:
        """Execute the debate pipeline."""
        start = time.monotonic()
        arbiter_reviews: list[ArbiterReview] = []
        all_messages: list[AgentMessage] = []
        timeout = self._config.default_timeout

        await self._emit(
            "pipeline_started",
            mode="debate",
            task_summary=self._task.task[:200],
            models=list(self._registry.keys()),
        )

        # Phase 1: Position papers
        logger.info(
            "Debate: position papers from %d models",
            len(self._registry),
        )
        await self._emit(
            "stage_started",
            stage="debate_proposals",
            model=f"{len(self._registry)} debaters",
        )
        proposals: dict[str, str] = {}
        prop_tasks = {}
        for key, cfg in self._registry.items():
            prop_tasks[key] = self._call_model(
                key, cfg, "debate_propose", timeout,
            )

        prop_results = await asyncio.gather(
            *prop_tasks.values(), return_exceptions=True,
        )
        for key, result in zip(prop_tasks, prop_results, strict=True):
            if isinstance(result, Exception):
                logger.error(
                    "Model %s failed in proposal: %s", key, result,
                )
                await self._emit(
                    "error",
                    stage="debate_proposals",
                    error=f"{key} failed: {result}",
                )
                continue
            proposals[key] = result.content
            all_messages.append(result)

        await self._emit(
            "stage_completed",
            stage="debate_proposals",
            model=f"{len(proposals)} proposals",
            duration=time.monotonic() - start,
        )

        if len(proposals) < 2:
            raise RuntimeError(
                f"Debate mode requires at least 2 proposals, "
                f"got {len(proposals)}"
            )

        # Phase 2: Rebuttals
        logger.info("Debate: rebuttals")
        await self._emit(
            "stage_started",
            stage="debate_rebuttals",
            model=f"{len(proposals)} rebuttals",
        )
        rebuttals: dict[str, dict[str, str]] = {}
        rebuttal_tasks = {}
        for key, cfg in self._registry.items():
            if key not in proposals:
                continue
            other_proposals = {
                k: v for k, v in proposals.items() if k != key
            }
            rebuttal_tasks[key] = self._call_model(
                key,
                cfg,
                "debate_rebuttal",
                timeout,
                own_proposal=proposals[key],
                other_proposals=other_proposals,
            )

        reb_results = await asyncio.gather(
            *rebuttal_tasks.values(), return_exceptions=True,
        )
        for key, result in zip(rebuttal_tasks, reb_results, strict=True):
            if isinstance(result, Exception):
                logger.error(
                    "Model %s failed in rebuttal: %s", key, result,
                )
                continue
            all_messages.append(result)
            other_keys = [k for k in proposals if k != key]
            rebuttals[key] = self._parse_rebuttals(
                result.content, other_keys,
            )

        await self._emit(
            "stage_completed",
            stage="debate_rebuttals",
            model=f"{len(rebuttals)} rebuttals",
            duration=time.monotonic() - start,
        )

        # Phase 3: Final arguments
        logger.info("Debate: final arguments")
        await self._emit(
            "stage_started",
            stage="debate_final_arguments",
            model=f"{len(proposals)} models",
        )
        final_arguments: dict[str, str] = {}
        final_tasks = {}
        for key, cfg in self._registry.items():
            if key not in proposals:
                continue
            rebuttals_received = {}
            for rebutter, targets in rebuttals.items():
                if key in targets:
                    rebuttals_received[rebutter] = targets[key]

            final_tasks[key] = self._call_model(
                key,
                cfg,
                "debate_final",
                timeout,
                own_proposal=proposals[key],
                rebuttals_received=rebuttals_received,
            )

        final_results = await asyncio.gather(
            *final_tasks.values(), return_exceptions=True,
        )
        for key, result in zip(final_tasks, final_results, strict=True):
            if isinstance(result, Exception):
                logger.error(
                    "Model %s failed in final argument: %s", key, result,
                )
                continue
            final_arguments[key] = result.content
            all_messages.append(result)

        await self._emit(
            "stage_completed",
            stage="debate_final_arguments",
            model=f"{len(final_arguments)} finals",
            duration=time.monotonic() - start,
        )

        # Phase 4: Judgment — select judge outside debaters when possible
        judge_key = self._select_judge(set(proposals.keys()))
        judge_cfg = self._full_registry[judge_key]
        logger.info("Debate: judgment by %s", judge_key)
        await self._emit(
            "stage_started",
            stage="debate_judgment",
            model=judge_key,
        )

        judge_msg = await self._call_model(
            judge_key,
            judge_cfg,
            "debate_judge",
            timeout,
            proposals=proposals,
            all_rebuttals=rebuttals,
            final_arguments=final_arguments,
            max_tokens=judge_cfg.max_output_tokens,
        )
        all_messages.append(judge_msg)
        judgment = judge_msg.content

        judge_cost = judge_msg.token_usage.cost if judge_msg.token_usage else 0.0
        await self._emit(
            "stage_completed",
            stage="debate_judgment",
            model=judge_key,
            duration=time.monotonic() - start,
            cost=judge_cost,
        )

        # Phase 5: Arbiter review of judgment
        # Exclude ALL debaters + judge from arbiter selection
        _participant_models = [
            self._registry[k].model for k in proposals
        ]
        _participant_models.append(judge_cfg.model)

        if self._config.arbiter_mode != ArbiterMode.OFF:
            review = await self._arbiter.review(
                stage=PipelineStage.VERIFY,
                stage_model=judge_cfg.model,
                stage_output=judgment,
                task=self._task,
                exclude_models=_participant_models,
            )
            if review is not None:
                arbiter_reviews.append(review)

        # Build result
        duration = time.monotonic() - start
        total_cost = sum(
            m.token_usage.cost for m in all_messages if m.token_usage
        ) + sum(r.token_cost for r in arbiter_reviews)
        total_tokens = sum(
            (m.token_usage.prompt_tokens + m.token_usage.completion_tokens)
            for m in all_messages
            if m.token_usage
        )

        halted = any(r.verdict == Verdict.HALT for r in arbiter_reviews)

        if not halted:
            await self._emit(
                "pipeline_completed",
                success=True,
                total_cost=total_cost,
                total_tokens=total_tokens,
                duration=duration,
            )

        return PipelineResult(
            task=self._task,
            config=self._config,
            total_cost=total_cost,
            total_tokens=total_tokens,
            duration_seconds=duration,
            success=not halted,
            arbiter_reviews=arbiter_reviews,
            halted=halted,
            halt_reason=(
                arbiter_reviews[-1].reasoning
                if halted and arbiter_reviews
                else ""
            ),
            debate_result=DebateResult(
                proposals=proposals,
                rebuttals=rebuttals,
                final_arguments=final_arguments,
                judgment=judgment,
                judge_model=judge_key,
            ),
        )

    def _select_judge(self, debater_keys: set[str]) -> str:
        """Select a judge model, preferring one outside the debaters.

        Falls back to the highest-fitness verifier among debaters if no
        external judge is available.
        """
        # Try to find a judge outside the debaters from the full registry
        external = {
            k: v for k, v in self._full_registry.items()
            if k not in debater_keys
        }
        if external:
            return max(external, key=lambda k: external[k].fitness.verifier)

        # No external models — fall back to best debater
        return _get_tiebreaker_key(self._registry)

    def _parse_rebuttals(
        self,
        content: str,
        target_keys: list[str],
    ) -> dict[str, str]:
        """Parse a combined rebuttal output into per-target rebuttals.

        Looks for '## Rebuttal: <model_key>' or '## <model_key>' headers.
        Also tries '### Rebuttal' or '## Response to <model_key>'. Falls
        back to assigning the entire content to all targets if parsing fails.
        """
        parsed: dict[str, str] = {}
        for key in target_keys:
            # Try several heading patterns
            patterns = [
                rf"##\s*Rebuttal:\s*{re.escape(key)}",
                rf"##\s*Response\s+to\s*{re.escape(key)}",
                rf"##\s*{re.escape(key)}",
                rf"###\s*Rebuttal:\s*{re.escape(key)}",
            ]
            match = None
            for pat in patterns:
                match = re.search(pat, content, re.IGNORECASE)
                if match:
                    break

            if match:
                start_pos = match.end()
                # Find the next heading at the same or higher level
                next_match = re.search(
                    r"\n##\s+",
                    content[start_pos:],
                )
                end = (
                    start_pos + next_match.start()
                    if next_match
                    else len(content)
                )
                parsed[key] = content[start_pos:end].strip()
            else:
                parsed[key] = content

        return parsed

    _MAX_CALL_ATTEMPTS = 2

    async def _call_model(
        self,
        model_key: str,
        model_config: ModelConfig,
        template_name: str,
        timeout: int,
        **template_vars: object,
    ) -> AgentMessage:
        """Call a model with a rendered prompt template.

        Retries once with a fallback model from the full registry if the
        primary call fails with a transient error.

        Pass ``max_tokens=<int>`` alongside template vars to override the
        model's default max output tokens for this call.
        """
        call_max_tokens: int | None = template_vars.pop("max_tokens", None)
        tpl_vars: dict = {
            "task": self._task.task,
            "context": self._task.context,
            "domain_context": self._task.domain_rules,
            **template_vars,
        }
        system = render_prompt(template_name, **tpl_vars)
        tried: list[str] = []

        for attempt in range(self._MAX_CALL_ATTEMPTS):
            if attempt == 0:
                cfg = model_config
                key = model_key
            else:
                candidates = {
                    k: v for k, v in self._full_registry.items()
                    if k not in tried and self._health.is_healthy(k)
                }
                if not candidates:
                    break
                key = max(
                    candidates,
                    key=lambda k: (
                        candidates[k].fitness.architect
                        + candidates[k].fitness.implementer
                    ) / 2,
                )
                cfg = candidates[key]
                logger.info(
                    "Debate fallback %s → %s", model_key, key,
                )

            tried.append(key)
            try:
                provider = LiteLLMProvider(cfg)
                logger.debug(
                    "Debate call: template=%s, model=%s (%s), "
                    "max_tokens=%s, cfg.max_output_tokens=%s, system_len=%d",
                    template_name, key, cfg.model,
                    call_max_tokens, cfg.max_output_tokens,
                    len(system),
                )
                msg = await provider.complete(
                    messages=[{
                        "role": "user",
                        "content": (
                            "Execute the task as described "
                            "in your system instructions."
                        ),
                    }],
                    system=system,
                    timeout=timeout,
                    max_tokens=call_max_tokens,
                )
                logger.debug(
                    "Debate call returned: template=%s, model=%s, "
                    "content_len=%d, prompt_tok=%s, completion_tok=%s",
                    template_name, key, len(msg.content),
                    msg.token_usage.prompt_tokens if msg.token_usage else "?",
                    msg.token_usage.completion_tokens if msg.token_usage else "?",
                )
                msg.from_agent = PipelineStage.ARCHITECT
                msg.to_agent = PipelineStage.ARCHITECT
                msg.msg_type = MessageType.PROPOSAL
                msg.confidence = _extract_confidence(msg.content)
                return msg
            except (RuntimeError, TimeoutError) as e:
                self._health.mark_unhealthy(key)
                logger.warning(
                    "%s failed in debate call (%s), %s",
                    key,
                    _short_error_reason(e),
                    "falling back" if attempt < self._MAX_CALL_ATTEMPTS - 1 else "giving up",
                )
                last_error = e

        raise RuntimeError(
            f"All models failed for debate {template_name}: "
            f"tried {tried}. Last error: {last_error}"
        )


# ── Review Orchestrator ──────────────────────────────────────────


class ReviewOrchestrator:
    """Multi-model code review pipeline mode.

    All models independently analyze the source code, cross-review each
    other's analyses, and a synthesizer merges everything into a unified
    review. Optional arbiter review of the synthesis.
    """

    _MAX_REVIEW_MODELS = 3

    def __init__(
        self,
        task: TaskSpec,
        config: PipelineConfig,
        registry: dict[str, ModelConfig],
        event_emitter: object | None = None,
    ) -> None:
        self._task = task
        self._config = config
        self._full_registry = registry
        self._registry = _select_top_models(registry, self._MAX_REVIEW_MODELS)
        self._emitter = event_emitter
        self._health = ProviderHealth()
        self._arbiter = ArbiterEngine(config, registry, health=self._health)

    async def _emit(self, event_type: str, **data: object) -> None:
        """Emit a dashboard event if an emitter is attached."""
        if self._emitter is not None:
            await self._emitter.emit(event_type, **data)

    async def run(self) -> PipelineResult:
        """Execute the review pipeline."""
        start = time.monotonic()
        arbiter_reviews: list[ArbiterReview] = []
        all_messages: list[AgentMessage] = []
        timeout = self._config.default_timeout

        # Extract source_code and focus from task/context
        source_code = self._task.context or ""
        focus = ""

        await self._emit(
            "pipeline_started",
            mode="review",
            task_summary=self._task.task[:200],
            models=list(self._registry.keys()),
        )

        # Phase 1: Fan-out — all models analyze independently
        logger.info("Review fan-out: %d models", len(self._registry))
        await self._emit(
            "stage_started",
            stage="parallel_fan_out",
            model=f"{len(self._registry)} models",
        )
        individual_analyses: dict[str, str] = {}
        fan_out_tasks = {}
        for key, cfg in self._registry.items():
            fan_out_tasks[key] = self._call_model(
                key, cfg, "review_analyze", timeout,
                source_code=source_code,
                focus=focus,
            )

        results = await asyncio.gather(
            *fan_out_tasks.values(), return_exceptions=True,
        )
        for key, result in zip(fan_out_tasks, results, strict=True):
            if isinstance(result, Exception):
                logger.error("Model %s failed in review fan-out: %s", key, result)
                await self._emit(
                    "error",
                    stage="parallel_fan_out",
                    error=f"{key} failed: {result}",
                )
                continue
            individual_analyses[key] = result.content
            all_messages.append(result)

        fan_out_cost = sum(
            m.token_usage.cost for m in all_messages if m.token_usage
        )
        await self._emit(
            "stage_completed",
            stage="parallel_fan_out",
            model=f"{len(individual_analyses)} succeeded",
            duration=time.monotonic() - start,
            cost=fan_out_cost,
        )

        if len(individual_analyses) == 0:
            raise RuntimeError(
                "Review mode: all models failed during analysis"
            )

        # Phase 2: Cross-review — each model reviews others' analyses
        logger.info("Review cross-review")
        await self._emit(
            "stage_started",
            stage="parallel_cross_review",
            model=f"{len(individual_analyses)} reviewers",
        )
        cross_reviews: dict[str, dict[str, str]] = {}
        review_tasks = []
        review_keys: list[tuple[str, str]] = []

        for reviewer_key in self._registry:
            if reviewer_key not in individual_analyses:
                continue
            reviewer_cfg = self._registry[reviewer_key]
            for reviewed_key, analysis in individual_analyses.items():
                if reviewed_key == reviewer_key:
                    continue
                review_tasks.append(
                    self._call_model(
                        reviewer_key,
                        reviewer_cfg,
                        "review_cross_review",
                        timeout,
                        source_code=source_code,
                        analysis_under_review=analysis,
                        reviewer_model=reviewed_key,
                    )
                )
                review_keys.append((reviewer_key, reviewed_key))

        review_results = await asyncio.gather(
            *review_tasks, return_exceptions=True,
        )
        for (reviewer, reviewed), result in zip(
            review_keys, review_results, strict=True,
        ):
            if isinstance(result, Exception):
                logger.error(
                    "Cross-review %s->%s failed: %s", reviewer, reviewed, result,
                )
                continue
            all_messages.append(result)
            if reviewer not in cross_reviews:
                cross_reviews[reviewer] = {}
            cross_reviews[reviewer][reviewed] = result.content

        review_cost = sum(
            m.token_usage.cost for m in all_messages if m.token_usage
        ) - fan_out_cost
        await self._emit(
            "stage_completed",
            stage="parallel_cross_review",
            model=f"{len(review_keys)} reviews",
            duration=time.monotonic() - start,
            cost=review_cost,
        )

        # Phase 3: Synthesis — merge all analyses
        synth_key = "gpt-4o"
        synth_cfg = self._full_registry.get(synth_key)
        if synth_cfg is None:
            synth_key = next(iter(individual_analyses))
            synth_cfg = self._registry[synth_key]
        logger.info("Review synthesis by %s", synth_key)
        await self._emit(
            "stage_started",
            stage="parallel_synthesis",
            model=synth_key,
        )

        synth_msg = await self._call_model(
            synth_key,
            synth_cfg,
            "review_synthesize",
            timeout,
            source_code=source_code,
            all_analyses=individual_analyses,
            focus=focus,
        )
        all_messages.append(synth_msg)
        synthesized_review = synth_msg.content

        synth_cost = synth_msg.token_usage.cost if synth_msg.token_usage else 0.0
        await self._emit(
            "stage_completed",
            stage="parallel_synthesis",
            model=synth_key,
            duration=time.monotonic() - start,
            cost=synth_cost,
        )

        # Phase 4: Arbiter review of synthesis
        # Exclude ALL reviewers + synthesizer from arbiter selection
        _participant_models = [
            self._registry[k].model for k in individual_analyses
        ]
        _participant_models.append(synth_cfg.model)

        if self._config.arbiter_mode != ArbiterMode.OFF:
            review = await self._arbiter.review(
                stage=PipelineStage.VERIFY,
                stage_model=synth_cfg.model,
                stage_output=synthesized_review,
                task=self._task,
                exclude_models=_participant_models,
            )
            if review is not None:
                arbiter_reviews.append(review)

        # Build result
        duration = time.monotonic() - start
        total_cost = sum(
            m.token_usage.cost for m in all_messages if m.token_usage
        ) + sum(r.token_cost for r in arbiter_reviews)
        total_tokens = sum(
            (m.token_usage.prompt_tokens + m.token_usage.completion_tokens)
            for m in all_messages
            if m.token_usage
        )

        halted = any(r.verdict == Verdict.HALT for r in arbiter_reviews)

        if not halted:
            await self._emit(
                "pipeline_completed",
                success=True,
                total_cost=total_cost,
                total_tokens=total_tokens,
                duration=duration,
            )

        return PipelineResult(
            task=self._task,
            config=self._config,
            total_cost=total_cost,
            total_tokens=total_tokens,
            duration_seconds=duration,
            success=not halted,
            arbiter_reviews=arbiter_reviews,
            halted=halted,
            halt_reason=(
                arbiter_reviews[-1].reasoning
                if halted and arbiter_reviews
                else ""
            ),
            review_result=ReviewResult(
                individual_analyses=individual_analyses,
                cross_reviews=cross_reviews,
                synthesized_review=synthesized_review,
            ),
        )

    _MAX_CALL_ATTEMPTS = 2

    async def _call_model(
        self,
        model_key: str,
        model_config: ModelConfig,
        template_name: str,
        timeout: int,
        **template_vars: object,
    ) -> AgentMessage:
        """Call a model with a rendered prompt template."""
        call_max_tokens: int | None = template_vars.pop("max_tokens", None)
        tpl_vars: dict = {
            "task": self._task.task,
            "context": self._task.context,
            "domain_context": self._task.domain_rules,
            **template_vars,
        }
        system = render_prompt(template_name, **tpl_vars)
        tried: list[str] = []

        for attempt in range(self._MAX_CALL_ATTEMPTS):
            if attempt == 0:
                cfg = model_config
                key = model_key
            else:
                candidates = {
                    k: v for k, v in self._full_registry.items()
                    if k not in tried and self._health.is_healthy(k)
                }
                if not candidates:
                    break
                key = max(
                    candidates,
                    key=lambda k: candidates[k].fitness.verifier,
                )
                cfg = candidates[key]
                logger.info(
                    "Review fallback %s → %s", model_key, key,
                )

            tried.append(key)
            try:
                provider = LiteLLMProvider(cfg)
                msg = await provider.complete(
                    messages=[{
                        "role": "user",
                        "content": (
                            "Execute the task as described "
                            "in your system instructions."
                        ),
                    }],
                    system=system,
                    timeout=timeout,
                    max_tokens=call_max_tokens,
                )
                msg.from_agent = PipelineStage.ARCHITECT
                msg.to_agent = PipelineStage.ARCHITECT
                msg.msg_type = MessageType.PROPOSAL
                msg.confidence = _extract_confidence(msg.content)
                return msg
            except (RuntimeError, TimeoutError) as e:
                self._health.mark_unhealthy(key)
                logger.warning(
                    "%s failed in review call (%s), %s",
                    key,
                    _short_error_reason(e),
                    "falling back" if attempt < self._MAX_CALL_ATTEMPTS - 1 else "giving up",
                )
                last_error = e

        raise RuntimeError(
            f"All models failed for review {template_name}: "
            f"tried {tried}. Last error: {last_error}"
        )


# ── Improve Orchestrator ────────────────────────────────────────


class ImproveOrchestrator:
    """Multi-model code improvement pipeline mode.

    All models independently produce improved versions of the source code,
    cross-review each other's improvements, vote on a winner, and the
    winning approach is synthesized with the best elements from others.
    Follows the same pattern as ParallelOrchestrator.
    """

    _MAX_IMPROVE_MODELS = 3

    def __init__(
        self,
        task: TaskSpec,
        config: PipelineConfig,
        registry: dict[str, ModelConfig],
        event_emitter: object | None = None,
    ) -> None:
        self._task = task
        self._config = config
        self._full_registry = registry
        self._registry = _select_top_models(registry, self._MAX_IMPROVE_MODELS)
        self._emitter = event_emitter
        self._health = ProviderHealth()
        self._arbiter = ArbiterEngine(config, registry, health=self._health)
        self._consensus = ConsensusEngine(config, registry)

    async def _emit(self, event_type: str, **data: object) -> None:
        """Emit a dashboard event if an emitter is attached."""
        if self._emitter is not None:
            await self._emitter.emit(event_type, **data)

    async def run(self) -> PipelineResult:
        """Execute the improve pipeline."""
        start = time.monotonic()
        arbiter_reviews: list[ArbiterReview] = []
        all_messages: list[AgentMessage] = []
        timeout = self._config.default_timeout

        # Extract source_code and focus from task/context
        source_code = self._task.context or ""
        focus = ""

        await self._emit(
            "pipeline_started",
            mode="improve",
            task_summary=self._task.task[:200],
            models=list(self._registry.keys()),
        )

        # Phase 1: Fan-out — all models generate improvements in parallel
        logger.info("Improve fan-out: %d models", len(self._registry))
        await self._emit(
            "stage_started",
            stage="parallel_fan_out",
            model=f"{len(self._registry)} models",
        )
        individual_outputs: dict[str, str] = {}
        fan_out_tasks = {}
        for key, cfg in self._registry.items():
            fan_out_tasks[key] = self._call_model(
                key, cfg, "improve_generate", timeout,
                source_code=source_code,
                focus=focus,
            )

        results = await asyncio.gather(
            *fan_out_tasks.values(), return_exceptions=True,
        )
        for key, result in zip(fan_out_tasks, results, strict=True):
            if isinstance(result, Exception):
                logger.error("Model %s failed in improve fan-out: %s", key, result)
                await self._emit(
                    "error",
                    stage="parallel_fan_out",
                    error=f"{key} failed: {result}",
                )
                continue
            individual_outputs[key] = result.content
            all_messages.append(result)

        fan_out_cost = sum(
            m.token_usage.cost for m in all_messages if m.token_usage
        )
        await self._emit(
            "stage_completed",
            stage="parallel_fan_out",
            model=f"{len(individual_outputs)} succeeded",
            duration=time.monotonic() - start,
            cost=fan_out_cost,
        )

        if len(individual_outputs) == 0:
            raise RuntimeError(
                "Improve mode: all models failed during generation"
            )

        if len(individual_outputs) == 1:
            sole_key = next(iter(individual_outputs))
            sole_output = individual_outputs[sole_key]
            logger.warning(
                "Improve fan-out: only %s succeeded, falling back to single-model output",
                sole_key,
            )
            duration = time.monotonic() - start
            total_cost = sum(
                m.token_usage.cost for m in all_messages if m.token_usage
            )
            total_tokens = sum(
                (m.token_usage.prompt_tokens + m.token_usage.completion_tokens)
                for m in all_messages
                if m.token_usage
            )
            await self._emit(
                "pipeline_completed",
                success=True,
                total_cost=total_cost,
                total_tokens=total_tokens,
                duration=duration,
            )
            return PipelineResult(
                task=self._task,
                config=self._config,
                total_cost=total_cost,
                total_tokens=total_tokens,
                duration_seconds=duration,
                success=True,
                arbiter_reviews=arbiter_reviews,
                improve_result=ImproveResult(
                    individual_outputs=individual_outputs,
                    scores={},
                    votes={sole_key: sole_key},
                    winner=sole_key,
                    synthesized_output=sole_output,
                ),
            )

        # Phase 2: Cross-review — each model scores the others
        logger.info("Improve cross-review")
        await self._emit(
            "stage_started",
            stage="parallel_cross_review",
            model=f"{len(individual_outputs)} reviewers",
        )
        scores: dict[str, dict[str, dict[str, int]]] = {}
        review_tasks = []
        review_keys: list[tuple[str, str]] = []

        for reviewer_key in self._registry:
            if reviewer_key not in individual_outputs:
                continue
            reviewer_cfg = self._registry[reviewer_key]
            for reviewed_key, output in individual_outputs.items():
                if reviewed_key == reviewer_key:
                    continue
                review_tasks.append(
                    self._call_model(
                        reviewer_key,
                        reviewer_cfg,
                        "improve_cross_review",
                        timeout,
                        source_code=source_code,
                        output_under_review=output,
                        reviewer_model=reviewed_key,
                    )
                )
                review_keys.append((reviewer_key, reviewed_key))

        review_results = await asyncio.gather(
            *review_tasks, return_exceptions=True,
        )
        for (reviewer, reviewed), result in zip(
            review_keys, review_results, strict=True,
        ):
            if isinstance(result, Exception):
                logger.error(
                    "Improve review %s->%s failed: %s", reviewer, reviewed, result,
                )
                continue
            all_messages.append(result)
            if reviewer not in scores:
                scores[reviewer] = {}
            scores[reviewer][reviewed] = _extract_scores(result.content)

        review_cost = sum(
            m.token_usage.cost for m in all_messages if m.token_usage
        ) - fan_out_cost
        await self._emit(
            "stage_completed",
            stage="parallel_cross_review",
            model=f"{len(review_keys)} reviews",
            duration=time.monotonic() - start,
            cost=review_cost,
        )

        # Phase 3: Vote — derive votes from scores
        votes: dict[str, str] = {}
        for voter, voter_scores in scores.items():
            if not voter_scores:
                continue
            votes[voter] = max(
                voter_scores,
                key=lambda k: sum(voter_scores[k].values()),
            )

        # Phase 4: Resolve consensus
        consensus_result = await self._consensus.resolve_consensus(
            votes=votes,
            task=self._task.task,
            candidate_outputs=individual_outputs,
        )
        winner = consensus_result.winner
        logger.info("Improve winner: %s (method=%s)", winner, consensus_result.method)

        await self._emit(
            "consensus_vote",
            votes=votes,
            winner=winner,
            method=consensus_result.method,
        )

        # Phase 5: Synthesis — merge winning output with best ideas
        synth_key = "gpt-4o"
        synth_cfg = self._full_registry.get(synth_key)
        if synth_cfg is None:
            synth_key = winner
            synth_cfg = self._registry[winner]
        logger.info("Improve synthesis by %s", synth_key)
        await self._emit(
            "stage_started",
            stage="parallel_synthesis",
            model=synth_key,
        )
        winner_cfg = self._registry[winner]
        other_outputs = {
            k: v for k, v in individual_outputs.items() if k != winner
        }
        synth_msg = await self._call_model(
            synth_key,
            synth_cfg,
            "improve_synthesize",
            timeout,
            source_code=source_code,
            winner_model=winner,
            winning_output=individual_outputs[winner],
            other_outputs=other_outputs,
        )
        all_messages.append(synth_msg)
        synthesized_output = synth_msg.content

        synth_cost = synth_msg.token_usage.cost if synth_msg.token_usage else 0.0
        await self._emit(
            "stage_completed",
            stage="parallel_synthesis",
            model=synth_key,
            duration=time.monotonic() - start,
            cost=synth_cost,
        )

        # Phase 6: Arbiter review + retry on REJECT
        # Exclude ALL fan-out participants + synthesizer from arbiter selection
        _participant_models = [
            self._registry[k].model for k in individual_outputs
        ]
        _participant_models.append(synth_cfg.model)

        _MAX_SYNTH_RETRIES = 2
        final_reject = False

        if self._config.arbiter_mode != ArbiterMode.OFF:
            review = await self._arbiter.review(
                stage=PipelineStage.VERIFY,
                stage_model=winner_cfg.model,
                stage_output=synthesized_output,
                task=self._task,
                exclude_models=_participant_models,
            )
            if review is not None:
                arbiter_reviews.append(review)

                if review.verdict == Verdict.REJECT:
                    for retry in range(1, _MAX_SYNTH_RETRIES + 1):
                        logger.info(
                            "Retrying improve synthesis (attempt %d/%d) after REJECT",
                            retry, _MAX_SYNTH_RETRIES,
                        )
                        feedback = format_arbiter_feedback(review, retry)
                        await self._emit(
                            "stage_started",
                            stage="parallel_synthesis_retry",
                            model=synth_key,
                        )
                        synth_msg = await self._call_model(
                            synth_key,
                            synth_cfg,
                            "parallel_synthesis_retry",
                            timeout,
                            previous_synthesis=synthesized_output,
                            arbiter_feedback=feedback,
                        )
                        all_messages.append(synth_msg)
                        synthesized_output = synth_msg.content

                        retry_cost = synth_msg.token_usage.cost if synth_msg.token_usage else 0.0
                        await self._emit(
                            "stage_completed",
                            stage="parallel_synthesis_retry",
                            model=synth_key,
                            duration=time.monotonic() - start,
                            cost=retry_cost,
                        )

                        review = await self._arbiter.review(
                            stage=PipelineStage.VERIFY,
                            stage_model=winner_cfg.model,
                            stage_output=synthesized_output,
                            task=self._task,
                            exclude_models=_participant_models,
                        )
                        if review is not None:
                            arbiter_reviews.append(review)
                            if review.verdict != Verdict.REJECT:
                                break
                        else:
                            break

                    final_reject = (
                        review is not None
                        and review.verdict == Verdict.REJECT
                    )
                    if final_reject:
                        logger.warning(
                            "Improve synthesis still REJECTED after %d retries",
                            _MAX_SYNTH_RETRIES,
                        )

        # Build result
        duration = time.monotonic() - start
        total_cost = sum(
            m.token_usage.cost for m in all_messages if m.token_usage
        ) + sum(r.token_cost for r in arbiter_reviews)
        total_tokens = sum(
            (m.token_usage.prompt_tokens + m.token_usage.completion_tokens)
            for m in all_messages
            if m.token_usage
        )

        halted = any(r.verdict == Verdict.HALT for r in arbiter_reviews)

        if not halted:
            await self._emit(
                "pipeline_completed",
                success=True,
                total_cost=total_cost,
                total_tokens=total_tokens,
                duration=duration,
            )

        return PipelineResult(
            task=self._task,
            config=self._config,
            total_cost=total_cost,
            total_tokens=total_tokens,
            duration_seconds=duration,
            success=not halted,
            arbiter_reviews=arbiter_reviews,
            halted=halted,
            halt_reason=(
                arbiter_reviews[-1].reasoning
                if halted and arbiter_reviews
                else ""
            ),
            improve_result=ImproveResult(
                individual_outputs=individual_outputs,
                scores=scores,
                votes=votes,
                winner=winner,
                synthesized_output=synthesized_output,
            ),
        )

    _MAX_CALL_ATTEMPTS = 2

    async def _call_model(
        self,
        model_key: str,
        model_config: ModelConfig,
        template_name: str,
        timeout: int,
        **template_vars: object,
    ) -> AgentMessage:
        """Call a model with a rendered prompt template."""
        call_max_tokens: int | None = template_vars.pop("max_tokens", None)
        tpl_vars: dict = {
            "task": self._task.task,
            "context": self._task.context,
            "domain_context": self._task.domain_rules,
            **template_vars,
        }
        system = render_prompt(template_name, **tpl_vars)
        tried: list[str] = []

        for attempt in range(self._MAX_CALL_ATTEMPTS):
            if attempt == 0:
                cfg = model_config
                key = model_key
            else:
                candidates = {
                    k: v for k, v in self._full_registry.items()
                    if k not in tried and self._health.is_healthy(k)
                }
                if not candidates:
                    break
                key = max(
                    candidates,
                    key=lambda k: (
                        candidates[k].fitness.architect
                        + candidates[k].fitness.implementer
                    ) / 2,
                )
                cfg = candidates[key]
                logger.info(
                    "Improve fallback %s → %s", model_key, key,
                )

            tried.append(key)
            try:
                provider = LiteLLMProvider(cfg)
                msg = await provider.complete(
                    messages=[{
                        "role": "user",
                        "content": (
                            "Execute the task as described "
                            "in your system instructions."
                        ),
                    }],
                    system=system,
                    timeout=timeout,
                    max_tokens=call_max_tokens,
                )
                msg.from_agent = PipelineStage.ARCHITECT
                msg.to_agent = PipelineStage.ARCHITECT
                msg.msg_type = MessageType.PROPOSAL
                msg.confidence = _extract_confidence(msg.content)
                return msg
            except (RuntimeError, TimeoutError) as e:
                self._health.mark_unhealthy(key)
                logger.warning(
                    "%s failed in improve call (%s), %s",
                    key,
                    _short_error_reason(e),
                    "falling back" if attempt < self._MAX_CALL_ATTEMPTS - 1 else "giving up",
                )
                last_error = e

        raise RuntimeError(
            f"All models failed for improve {template_name}: "
            f"tried {tried}. Last error: {last_error}"
        )


# ── Pipeline Dispatcher ──────────────────────────────────────────


async def run_pipeline(
    task: TaskSpec,
    config: PipelineConfig,
    registry: dict[str, ModelConfig],
    event_emitter: object | None = None,
    stream_callback: Callable | None = None,
) -> PipelineResult:
    """Dispatch to the appropriate pipeline mode and return a result.

    Routes to PipelineOrchestrator (sequential), ParallelOrchestrator,
    or DebateOrchestrator based on config.pipeline_mode. When context
    injection is enabled (context_dir set), scans the project and injects
    context into the task. When session persistence is enabled, saves the
    run to SQLite.

    Args:
        task: The task specification.
        config: Pipeline configuration.
        registry: Model registry.
        event_emitter: Optional dashboard event emitter for real-time updates.
        stream_callback: Optional callback for streaming token delivery.
    """
    session_id = str(uuid.uuid4())
    started_at = datetime.now(UTC)

    # Context injection
    if config.context_dir:
        task = _inject_context(task, config)

    if config.pipeline_mode == PipelineMode.PARALLEL:
        result = await ParallelOrchestrator(
            task, config, registry, event_emitter,
        ).run()
    elif config.pipeline_mode == PipelineMode.DEBATE:
        # Debate mode benefits from full arbiter review — every position
        # paper and rebuttal should be reviewed, not just bookends.
        if config.arbiter_mode == ArbiterMode.BOOKEND:
            config = config.model_copy(update={"arbiter_mode": ArbiterMode.FULL})
        result = await DebateOrchestrator(
            task, config, registry, event_emitter,
        ).run()
    elif config.pipeline_mode == PipelineMode.REVIEW:
        result = await ReviewOrchestrator(
            task, config, registry, event_emitter,
        ).run()
    elif config.pipeline_mode == PipelineMode.IMPROVE:
        result = await ImproveOrchestrator(
            task, config, registry, event_emitter,
        ).run()
    else:
        result = await PipelineOrchestrator(
            task, config, registry, event_emitter,
            stream_callback=stream_callback,
        ).run()

    result.session_id = session_id

    # Persist session if enabled
    if config.persist_sessions:
        await _save_session(result, started_at)

    return result


async def _save_session(result: PipelineResult, started_at: datetime) -> None:
    """Build a SessionRecord from the PipelineResult and persist it."""
    from triad.persistence.database import close_db, init_db
    from triad.persistence.session import SessionStore

    completed_at = datetime.now(UTC)

    # Convert stage AgentMessages to StageRecords
    stage_records: list[StageRecord] = []
    for stage, msg in result.stages.items():
        stage_records.append(StageRecord(
            stage=stage.value,
            model_key=msg.model,
            model_id=msg.model,
            content=msg.content,
            confidence=msg.confidence,
            cost=msg.token_usage.cost if msg.token_usage else 0.0,
            tokens=(
                (msg.token_usage.prompt_tokens + msg.token_usage.completion_tokens)
                if msg.token_usage
                else 0
            ),
            timestamp=msg.timestamp.isoformat(),
        ))

    record = SessionRecord(
        session_id=result.session_id,
        task=result.task,
        config=result.config,
        stages=stage_records,
        arbiter_reviews=result.arbiter_reviews,
        routing_decisions=result.routing_decisions,
        started_at=started_at,
        completed_at=completed_at,
        success=result.success,
        halted=result.halted,
        halt_reason=result.halt_reason,
        total_cost=result.total_cost,
        total_tokens=result.total_tokens,
        duration_seconds=result.duration_seconds,
        pipeline_mode=result.config.pipeline_mode.value,
    )

    try:
        db = await init_db(result.config.session_db_path)
        store = SessionStore(db)
        await store.save_session(record)
        await close_db(db)
        logger.info("Session %s persisted", result.session_id)
    except Exception:
        logger.exception("Failed to persist session %s", result.session_id)


def _inject_context(task: TaskSpec, config: PipelineConfig) -> TaskSpec:
    """Scan a project directory and inject context into the task spec.

    Returns a new TaskSpec with the scanned context prepended to
    the existing context field.
    """
    from triad.context.builder import ContextBuilder
    from triad.context.scanner import CodeScanner

    scanner = CodeScanner(
        root=config.context_dir,
        include=config.context_include,
        exclude=config.context_exclude,
    )
    files = scanner.scan()

    if not files:
        logger.info("Context injection: no files matched in %s", config.context_dir)
        return task

    builder = ContextBuilder(
        root_path=config.context_dir,
        token_budget=config.context_token_budget,
    )
    ctx = builder.build(files, task.task)

    logger.info(
        "Context injection: %d/%d files, ~%d tokens%s",
        ctx.files_included,
        ctx.files_scanned,
        ctx.token_estimate,
        " (truncated)" if ctx.truncated else "",
    )

    # Prepend project context to existing context
    new_context = ctx.context_text
    if task.context:
        new_context = f"{new_context}\n\n---\n\n{task.context}"

    return TaskSpec(
        task=task.task,
        context=new_context,
        domain_rules=task.domain_rules,
        output_dir=task.output_dir,
    )
